# SPDX-License-Identifier: MIT OR GPL-3.0-or-later import math import tempfile from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager from copy import deepcopy from os import environ from pathlib import Path from sys import float_info from typing import Optional, Union from uuid import uuid4 import bpy from bpy.types import ( ID, Armature, ArmatureModifier, Bone, Constraint, Context, CopyRotationConstraint, Curve, DampedTrackConstraint, Image, Material, Mesh, Node, Object, PoseBone, VectorFont, ) from mathutils import Matrix, Quaternion, Vector from ..common import convert, safe_removal, shader from ..common.char import INTERNAL_NAME_PREFIX from ..common.convert import Json from ..common.deep import make_json from ..common.gltf import pack_glb, parse_glb from ..common.logger import get_logger from ..common.preferences import ExportPreferencesProtocol from ..common.rotation import ( ROTATION_MODE_EULER, get_rotation_as_quaternion, set_rotation_without_mode_change, ) from ..common.version import get_addon_version from ..common.vrm1.human_bone import HumanBoneName from ..common.workspace import save_workspace from ..editor import search from ..editor.extension import get_armature_extension, get_material_extension from ..editor.mtoon1.property_group import ( Mtoon1SamplerPropertyGroup, Mtoon1TextureInfoPropertyGroup, link_or_unlink_gltf_material_nodes, ) from ..editor.spring_bone1.handler import sort_spring_bone_joints from ..editor.spring_bone1.property_group import ( SpringBone1JointPropertyGroup, SpringBone1SpringBonePropertyGroup, SpringBone1SpringPropertyGroup, ) from ..editor.t_pose import setup_humanoid_t_pose from ..editor.vrm1.property_group import ( Vrm1ExpressionPropertyGroup, Vrm1ExpressionsPropertyGroup, Vrm1FirstPersonPropertyGroup, Vrm1HumanBonesPropertyGroup, Vrm1HumanoidPropertyGroup, Vrm1LookAtPropertyGroup, Vrm1MetaPropertyGroup, ) from ..external.io_scene_gltf2_support import ( ExportSceneGltfArguments, export_scene_gltf, image_to_image_bytes, init_extras_export, ) from .abstract_base_vrm_exporter import ( AbstractBaseVrmExporter, assign_dict, force_apply_modifiers, ) logger = get_logger(__name__) class Vrm1Exporter(AbstractBaseVrmExporter): def __init__( self, context: Context, export_objects: Sequence[Object], armature: Object, export_preferences: ExportPreferencesProtocol, ) -> None: super().__init__(context, export_objects, armature) self.export_all_influences = ( export_preferences.enable_advanced_preferences and export_preferences.export_all_influences ) self.export_lights = ( export_preferences.enable_advanced_preferences and export_preferences.export_lights ) self.export_gltf_animations = ( export_preferences.enable_advanced_preferences and export_preferences.export_gltf_animations ) self.export_try_sparse_sk = ( export_preferences.enable_advanced_preferences and export_preferences.export_try_sparse_sk ) self.extras_main_armature_key = ( INTERNAL_NAME_PREFIX + self.export_id + "MainArmature" ) self.extras_bone_name_key = INTERNAL_NAME_PREFIX + self.export_id + "BoneName" self.extras_object_name_key = ( INTERNAL_NAME_PREFIX + self.export_id + "ObjectName" ) self.extras_material_name_key = ( INTERNAL_NAME_PREFIX + self.export_id + "MaterialName" ) @staticmethod def enter_overwrite_object_visibility_and_selection( context: Context, export_objects: Sequence[Object] ) -> Mapping[str, tuple[bool, bool]]: object_visibility_and_selection: dict[str, tuple[bool, bool]] = {} # https://projects.blender.org/blender/blender/issues/113378 context.view_layer.update() for obj in context.view_layer.objects: object_visibility_and_selection[obj.name] = ( obj.hide_get(), obj.select_get(), ) enabled = obj in export_objects obj.hide_set(not enabled) obj.select_set(enabled) return object_visibility_and_selection @staticmethod def leave_overwrite_object_visibility_and_selection( context: Context, object_visibility_and_selection: Mapping[str, tuple[bool, bool]], ) -> None: for object_name, ( hidden, selection, ) in object_visibility_and_selection.items(): restore_obj = context.blend_data.objects.get(object_name) if restore_obj: restore_obj.hide_set(hidden) restore_obj.select_set(selection) @contextmanager def overwrite_object_visibility_and_selection(self) -> Iterator[None]: object_visibility_and_selection = ( self.enter_overwrite_object_visibility_and_selection( self.context, self.export_objects ) ) try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: self.leave_overwrite_object_visibility_and_selection( self.context, object_visibility_and_selection ) @staticmethod def enter_save_selected_mesh_compat_objects( context: Context, armature: Object, original_export_objects: Sequence[Object] ) -> tuple[Mapping[str, str], Mapping[str, str]]: backup_obj_name_to_original_obj_name: dict[str, str] = {} backup_data_name_to_original_data_name: dict[str, str] = {} # https://projects.blender.org/blender/blender/issues/113378 context.view_layer.update() export_objects: list[Object] = [] # Duplicate objects. # New objects are used for export # Old objects are given backup names and restored after export # Also, object data is given backup names for obj in context.view_layer.objects: if obj not in original_export_objects: continue if obj.type not in search.MESH_CONVERTIBLE_OBJECT_TYPES: continue if not obj.select_get(): continue backup_obj_name = "Backup-Object-" + uuid4().hex original_obj_name = obj.name backup_obj_name_to_original_obj_name[backup_obj_name] = original_obj_name export_obj = obj.copy() obj.name = backup_obj_name export_obj.name = original_obj_name export_objects.append(export_obj) obj.select_set(False) scene_collection_objects = context.scene.collection.objects scene_collection_objects.link(export_obj) export_obj.select_set(True) obj_data = obj.data if not obj_data: continue if obj_data.name in backup_data_name_to_original_data_name: continue backup_data_name = "Backup-Data-" + uuid4().hex backup_data_name_to_original_data_name[backup_data_name] = obj_data.name obj_data.name = backup_data_name # Duplicate object data # However, if active modifiers other than Armature modifiers are attached, # share the object data data_to_non_sharing_objects: dict[ID, list[Object]] = {} data_to_sharing_objects: dict[ID, list[Object]] = {} for export_object in export_objects: data = export_object.data if not data: continue sharing = True for modifier in export_object.modifiers: if not modifier.show_viewport: continue if ( isinstance(modifier, ArmatureModifier) and modifier.object == armature ): continue sharing = False break mapping = ( data_to_sharing_objects if sharing else data_to_non_sharing_objects ) objs = mapping.get(data) if objs is None: objs = list[Object]() mapping[data] = objs objs.append(export_object) for data, sharing_export_objects in data_to_sharing_objects.items(): original_data_name = backup_data_name_to_original_data_name.get(data.name) if not original_data_name: continue # This should not happen export_data = data.copy() export_data.name = original_data_name for sharing_export_object in sharing_export_objects: sharing_export_object.data = export_data for data, non_sharing_export_objects in data_to_non_sharing_objects.items(): original_data_name = backup_data_name_to_original_data_name.get(data.name) if not original_data_name: continue # This should not happen for non_sharing_export_object in non_sharing_export_objects: export_data = data.copy() export_data.name = original_data_name non_sharing_export_object.data = export_data return ( backup_obj_name_to_original_obj_name, backup_data_name_to_original_data_name, ) @staticmethod def leave_save_selected_mesh_compat_objects( context: Context, backup_obj_name_to_original_obj_name: Mapping[str, str], backup_data_name_to_original_data_name: Mapping[str, str], ) -> None: removing_object_datum: list[ID] = [] # Delete export objects for original_obj_name in backup_obj_name_to_original_obj_name.values(): restored_export_obj = context.blend_data.objects.get(original_obj_name) if not restored_export_obj: continue # Data may be referenced by multiple objects, so # collect them here and delete them all at once later restored_export_obj_data = restored_export_obj.data if ( restored_export_obj_data and restored_export_obj_data not in removing_object_datum ): removing_object_datum.append(restored_export_obj_data) # Even if deletion fails, randomize the name to avoid # confusion with the original object restored_export_obj.name = "Export-" + uuid4().hex if not safe_removal.remove_object(context, restored_export_obj): logger.warning( 'Failed to remove "%s" with %d users (temp object for "%s")', restored_export_obj.name, restored_export_obj.users, original_obj_name, ) # Delete export object data for removing_object_data in removing_object_datum: # Even if deletion fails, randomize the name to avoid # confusion with the original object data removing_object_data.name = "Export-Data-" + uuid4().hex if removing_object_data.users: logger.warning( 'Failed to remove "%s" with %d users', removing_object_data.name, removing_object_data.users, ) elif isinstance(removing_object_data, Mesh): context.blend_data.meshes.remove(removing_object_data) elif isinstance(removing_object_data, Curve): context.blend_data.curves.remove(removing_object_data) elif isinstance(removing_object_data, VectorFont): context.blend_data.fonts.remove(removing_object_data) else: logger.warning( 'Failed to remove "%s" with %d users. Not implemented.', removing_object_data.name, removing_object_data.users, ) # Restore the names of backed up objects for ( backup_obj_name, original_obj_name, ) in backup_obj_name_to_original_obj_name.items(): restored_original_data_name = None restored_obj = context.blend_data.objects.get(backup_obj_name) if not restored_obj: continue restored_obj.name = original_obj_name restored_obj.select_set(True) # Restore the names of backed up object data restored_obj_data = restored_obj.data if not restored_obj_data: continue restored_original_data_name = backup_data_name_to_original_data_name.get( restored_obj_data.name ) if not restored_original_data_name: continue restored_obj_data.name = restored_original_data_name @contextmanager def save_selected_mesh_compat_objects(self) -> Iterator[Sequence[str]]: ( backup_obj_name_to_original_obj_name, backup_data_name_to_original_data_name, ) = self.enter_save_selected_mesh_compat_objects( self.context, self.armature, self.export_objects ) try: yield list(backup_obj_name_to_original_obj_name.values()) # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: self.leave_save_selected_mesh_compat_objects( self.context, backup_obj_name_to_original_obj_name, backup_data_name_to_original_data_name, ) def enter_mount_skinned_mesh_parent(self, armature: Object) -> Sequence[str]: mounted_object_names: list[str] = [] for obj in self.export_objects: if obj.type != "MESH" or not [ True for m in obj.modifiers if isinstance(m, ArmatureModifier) and m.object == armature ]: continue search_obj = obj while search_obj != armature: if search_obj.parent: search_obj = search_obj.parent continue mounted_object_names.append(search_obj.name) matrix_world = search_obj.matrix_world.copy() search_obj.parent = armature search_obj.matrix_world = matrix_world break return mounted_object_names def leave_mount_skinned_mesh_parent( self, mounted_object_names: Sequence[str] ) -> None: for mounted_object_name in mounted_object_names: restore_obj = self.context.blend_data.objects.get(mounted_object_name) if not restore_obj: continue matrix_world = restore_obj.matrix_world.copy() restore_obj.parent = None restore_obj.matrix_world = matrix_world @contextmanager def mount_skinned_mesh_parent(self) -> Iterator[None]: # The glTF 2.0 export processing of the addon included with Blender 3.1.2 # outputs invalid glb with incorrect inverseBindMatrices when all of the # following conditions are met: # - A mesh that is not a descendant of the armature is skinned to bones # of that armature # - Another mesh exists as a child of the skinned bone # Therefore, make the ancestor parent of meshes that are not descendants # of the armature as the armature, and restore it later armature = self.armature if not armature: yield return if bpy.app.version >= (3, 2, 1): yield return mounted_object_names = self.enter_mount_skinned_mesh_parent(armature) try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: self.leave_mount_skinned_mesh_parent(mounted_object_names) @classmethod def create_meta_dict( cls, meta: Vrm1MetaPropertyGroup, json_dict: dict[str, Json], buffer0: bytearray, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> dict[str, Json]: meta_dict: dict[str, Json] = { "licenseUrl": "https://vrm.dev/licenses/1.0/", "name": meta.vrm_name if meta.vrm_name else "undefined", "version": meta.version if meta.version else "undefined", "avatarPermission": meta.avatar_permission, "allowExcessivelyViolentUsage": meta.allow_excessively_violent_usage, "allowExcessivelySexualUsage": meta.allow_excessively_sexual_usage, "commercialUsage": meta.commercial_usage, "allowPoliticalOrReligiousUsage": meta.allow_political_or_religious_usage, "allowAntisocialOrHateUsage": meta.allow_antisocial_or_hate_usage, "creditNotation": meta.credit_notation, "allowRedistribution": meta.allow_redistribution, "modification": meta.modification, } authors = [author.value for author in meta.authors if author.value] if not authors: authors = ["undefined"] meta_dict["authors"] = make_json(authors) if meta.copyright_information: meta_dict["copyrightInformation"] = meta.copyright_information references: list[Json] = [ reference.value for reference in meta.references if reference.value ] if references: meta_dict["references"] = references if meta.third_party_licenses: meta_dict["thirdPartyLicenses"] = meta.third_party_licenses if meta.other_license_url: meta_dict["otherLicenseUrl"] = meta.other_license_url if meta.thumbnail_image: meta_dict["thumbnailImage"] = cls.find_or_create_image( json_dict, buffer0, image_name_to_index_dict, meta.thumbnail_image, gltf2_addon_export_settings, ) return meta_dict @classmethod def create_humanoid_dict( cls, humanoid: Vrm1HumanoidPropertyGroup, bone_name_to_index_dict: Mapping[str, int], ) -> dict[str, Json]: human_bones_dict: dict[str, Json] = {} for ( human_bone_name, human_bone, ) in humanoid.human_bones.human_bone_name_to_human_bone().items(): index = bone_name_to_index_dict.get(human_bone.node.bone_name) if isinstance(index, int): human_bones_dict[human_bone_name.value] = {"node": index} return { "humanBones": human_bones_dict, } @classmethod def create_first_person_dict( cls, first_person: Vrm1FirstPersonPropertyGroup, mesh_object_name_to_node_index_dict: Mapping[str, int], ) -> dict[str, Json]: mesh_annotation_dicts: list[Json] = [] for mesh_annotation in first_person.mesh_annotations: if not mesh_annotation.node or not mesh_annotation.node.mesh_object_name: continue node_index = mesh_object_name_to_node_index_dict.get( mesh_annotation.node.mesh_object_name ) if not isinstance(node_index, int): continue mesh_annotation_dicts.append( { "node": node_index, "type": mesh_annotation.type, } ) if not mesh_annotation_dicts: return {} return {"meshAnnotations": mesh_annotation_dicts} @classmethod def create_look_at_dict( cls, look_at: Vrm1LookAtPropertyGroup, ) -> dict[str, Json]: return { "offsetFromHeadBone": list(look_at.offset_from_head_bone), "type": look_at.type, "rangeMapHorizontalInner": { "inputMaxValue": look_at.range_map_horizontal_inner.input_max_value, "outputScale": look_at.range_map_horizontal_inner.output_scale, }, "rangeMapHorizontalOuter": { "inputMaxValue": look_at.range_map_horizontal_outer.input_max_value, "outputScale": look_at.range_map_horizontal_outer.output_scale, }, "rangeMapVerticalDown": { "inputMaxValue": look_at.range_map_vertical_down.input_max_value, "outputScale": look_at.range_map_vertical_down.output_scale, }, "rangeMapVerticalUp": { "inputMaxValue": look_at.range_map_vertical_up.input_max_value, "outputScale": look_at.range_map_vertical_up.output_scale, }, } @classmethod def create_expression_dict( cls, expression: Vrm1ExpressionPropertyGroup, mesh_object_name_to_node_index_dict: Mapping[str, int], mesh_object_name_to_morph_target_names_dict: Mapping[str, list[str]], material_name_to_index_dict: Mapping[str, int], ) -> dict[str, Json]: expression_dict: dict[str, Json] = { "isBinary": expression.is_binary, "overrideBlink": expression.override_blink, "overrideLookAt": expression.override_look_at, "overrideMouth": expression.override_mouth, } morph_target_bind_dicts: list[Json] = [] for morph_target_bind in expression.morph_target_binds: if ( not morph_target_bind.node or not morph_target_bind.node.mesh_object_name ): continue node_index = mesh_object_name_to_node_index_dict.get( morph_target_bind.node.mesh_object_name ) if not isinstance(node_index, int): continue morph_targets = mesh_object_name_to_morph_target_names_dict.get( morph_target_bind.node.mesh_object_name ) if not isinstance(morph_targets, list): continue if morph_target_bind.index not in morph_targets: continue morph_target_bind_dicts.append( { "node": node_index, "index": morph_targets.index(morph_target_bind.index), "weight": morph_target_bind.weight, } ) if morph_target_bind_dicts: expression_dict["morphTargetBinds"] = morph_target_bind_dicts material_color_bind_dicts: list[Json] = [] for material_color_bind in expression.material_color_binds: material_color_bind_material = material_color_bind.material if ( not material_color_bind_material or not material_color_bind_material.name ): continue material_index = material_name_to_index_dict.get( material_color_bind_material.name ) if not isinstance(material_index, int): continue if material_color_bind.type == "color": target_value: Json = list(material_color_bind.target_value) else: rgb = material_color_bind.target_value_as_rgb target_value = [rgb[0], rgb[1], rgb[2], 1.0] material_color_bind_dicts.append( { "material": material_index, "type": material_color_bind.type, "targetValue": target_value, } ) if material_color_bind_dicts: expression_dict["materialColorBinds"] = material_color_bind_dicts texture_transform_binds: list[Json] = [] for texture_transform_bind in expression.texture_transform_binds: texture_transform_bind_material = texture_transform_bind.material if ( not texture_transform_bind_material or not texture_transform_bind_material.name ): continue material_index = material_name_to_index_dict.get( texture_transform_bind_material.name ) if not isinstance(material_index, int): continue texture_transform_binds.append( { "material": material_index, "scale": list(texture_transform_bind.scale), "offset": list(texture_transform_bind.offset), } ) if texture_transform_binds: expression_dict["textureTransformBinds"] = texture_transform_binds return expression_dict @classmethod def create_expressions_dict( cls, expressions: Vrm1ExpressionsPropertyGroup, mesh_object_name_to_node_index_dict: Mapping[str, int], mesh_object_name_to_morph_target_names_dict: Mapping[str, list[str]], material_name_to_index_dict: dict[str, int], ) -> dict[str, Json]: preset_dict: dict[str, Json] = {} for ( preset_name, expression, ) in expressions.preset.name_to_expression_dict().items(): preset_dict[preset_name] = cls.create_expression_dict( expression, mesh_object_name_to_node_index_dict, mesh_object_name_to_morph_target_names_dict, material_name_to_index_dict, ) custom_dict: dict[str, Json] = {} for custom_expression in expressions.custom: custom_dict[custom_expression.custom_name] = cls.create_expression_dict( custom_expression, mesh_object_name_to_node_index_dict, mesh_object_name_to_morph_target_names_dict, material_name_to_index_dict, ) return { "preset": preset_dict, "custom": custom_dict, } @classmethod def create_spring_bone_collider_dicts( cls, extensions_used: list[Json], spring_bone: SpringBone1SpringBonePropertyGroup, bone_name_to_index_dict: Mapping[str, int], ) -> tuple[list[Json], dict[str, int]]: collider_dicts: list[Json] = [] collider_uuid_to_index_dict: dict[str, int] = {} for collider in spring_bone.colliders: collider_dict: dict[str, Json] = {} node_index = bone_name_to_index_dict.get(collider.node.bone_name) if not isinstance(node_index, int): continue collider_dict["node"] = node_index extended_collider = collider.extensions.vrmc_spring_bone_extended_collider if collider.shape_type == collider.SHAPE_TYPE_SPHERE.identifier: if not extended_collider.enabled: sphere_offset: Json = list(collider.shape.sphere.offset) sphere_radius = collider.shape.sphere.radius elif not extended_collider.automatic_fallback_generation: sphere_offset = list(collider.shape.sphere.fallback_offset) sphere_radius = collider.shape.sphere.fallback_radius elif ( extended_collider.shape_type == extended_collider.SHAPE_TYPE_EXTENDED_SPHERE.identifier and not extended_collider.shape.sphere.inside ): sphere_offset = list(extended_collider.shape.sphere.offset) sphere_radius = extended_collider.shape.sphere.radius else: sphere_offset = [0, -10000, 0] sphere_radius = 0.00001 sphere_dict: dict[str, Json] = { "offset": sphere_offset, "radius": sphere_radius, } shape_dict: dict[str, Json] = {"sphere": sphere_dict} elif collider.shape_type == collider.SHAPE_TYPE_CAPSULE.identifier: if not extended_collider.enabled: capsule_offset: Json = list(collider.shape.capsule.offset) capsule_radius = collider.shape.capsule.radius capsule_tail: Json = list(collider.shape.capsule.tail) elif not extended_collider.automatic_fallback_generation: capsule_offset = list(collider.shape.capsule.fallback_offset) capsule_radius = collider.shape.capsule.fallback_radius capsule_tail = list(collider.shape.capsule.fallback_tail) elif ( extended_collider.shape_type == extended_collider.SHAPE_TYPE_EXTENDED_CAPSULE.identifier and not extended_collider.shape.capsule.inside ): capsule_offset = list(extended_collider.shape.capsule.offset) capsule_radius = extended_collider.shape.capsule.radius capsule_tail = list(extended_collider.shape.capsule.tail) else: capsule_offset = [0, -10000, 0] capsule_radius = 0.00001 capsule_tail = [0, -10001, 0] capsule_dict: dict[str, Json] = { "offset": capsule_offset, "radius": capsule_radius, "tail": capsule_tail, } shape_dict = {"capsule": capsule_dict} else: continue collider_dict["shape"] = shape_dict collider_uuid_to_index_dict[collider.uuid] = len(collider_dicts) collider_dicts.append(collider_dict) if not extended_collider.enabled: continue if ( extended_collider.shape_type == extended_collider.SHAPE_TYPE_EXTENDED_SPHERE.identifier ): extended_shape_dict: dict[str, Json] = { "sphere": { "offset": list(extended_collider.shape.sphere.offset), "radius": extended_collider.shape.sphere.radius, "inside": extended_collider.shape.sphere.inside, } } elif ( extended_collider.shape_type == extended_collider.SHAPE_TYPE_EXTENDED_CAPSULE.identifier ): extended_shape_dict = { "capsule": { "offset": list(extended_collider.shape.capsule.offset), "radius": extended_collider.shape.capsule.radius, "tail": list(extended_collider.shape.capsule.tail), "inside": extended_collider.shape.capsule.inside, } } elif ( extended_collider.shape_type == extended_collider.SHAPE_TYPE_EXTENDED_PLANE.identifier ): extended_shape_dict = { "plane": { "offset": list(extended_collider.shape.plane.offset), "normal": list(extended_collider.shape.plane.normal), } } else: continue collider_dict["extensions"] = { "VRMC_springBone_extended_collider": { "specVersion": "1.0", "shape": extended_shape_dict, } } if "VRMC_springBone_extended_collider" not in extensions_used: extensions_used.append("VRMC_springBone_extended_collider") return collider_dicts, collider_uuid_to_index_dict @classmethod def create_spring_bone_collider_group_dicts( cls, spring_bone: SpringBone1SpringBonePropertyGroup, collider_uuid_to_index_dict: Mapping[str, int], ) -> tuple[list[Json], dict[str, int]]: collider_group_dicts: list[Json] = [] collider_group_uuid_to_index_dict: dict[str, int] = {} for collider_group in spring_bone.collider_groups: collider_group_dict: dict[str, Json] = {"name": collider_group.vrm_name} collider_indices: list[Json] = [] for collider_reference in collider_group.colliders: collider_index = collider_uuid_to_index_dict.get( collider_reference.collider_uuid ) if isinstance(collider_index, int): collider_indices.append(collider_index) if not collider_indices: continue collider_group_dict["colliders"] = collider_indices collider_group_uuid_to_index_dict[collider_group.uuid] = len( collider_group_dicts ) collider_group_dicts.append(collider_group_dict) return collider_group_dicts, collider_group_uuid_to_index_dict @classmethod def create_spring_bone_spring_dicts( cls, spring_bone: SpringBone1SpringBonePropertyGroup, bone_name_to_index_dict: Mapping[str, int], collider_group_uuid_to_index_dict: Mapping[str, int], armature: Object, ) -> list[Json]: spring_dicts: list[Json] = [] armature_data = armature.data if not isinstance(armature_data, Armature): logger.error("%s is not an Armature", type(armature_data)) return [] sorted_spring_and_joints: list[ tuple[ SpringBone1SpringPropertyGroup, Sequence[SpringBone1JointPropertyGroup] ] ] = [ (spring, [joint for joint, _ in joint_and_bones]) for spring in spring_bone.springs for joint_and_bones in sort_spring_bone_joints(armature, spring.joints) ] for spring, joints in sorted_spring_and_joints: spring_dict: dict[str, Json] = {"name": spring.vrm_name} first_bone: Optional[Bone] = None joint_dicts: list[Json] = [] for joint in joints: bone = armature_data.bones.get(joint.node.bone_name) if not bone: continue if first_bone is None: first_bone = bone node_index = bone_name_to_index_dict.get(joint.node.bone_name) if not isinstance(node_index, int): continue joint_dicts.append( { "node": node_index, "hitRadius": joint.hit_radius, "stiffness": joint.stiffness, "gravityPower": joint.gravity_power, "gravityDir": [ joint.gravity_dir[0], joint.gravity_dir[2], -joint.gravity_dir[1], ], "dragForce": joint.drag_force, } ) if not joint_dicts: continue spring_dict["joints"] = joint_dicts center_bone = armature_data.bones.get(spring.center.bone_name) if center_bone: center_bone_is_ancestor_of_first_bone = False ancestor_of_first_bone = first_bone while ancestor_of_first_bone: if center_bone == ancestor_of_first_bone: center_bone_is_ancestor_of_first_bone = True break ancestor_of_first_bone = ancestor_of_first_bone.parent if center_bone_is_ancestor_of_first_bone: center_index = bone_name_to_index_dict.get(spring.center.bone_name) if isinstance(center_index, int): spring_dict["center"] = center_index collider_group_indices: list[Json] = [] for collider_group_reference in spring.collider_groups: collider_group_index = collider_group_uuid_to_index_dict.get( collider_group_reference.collider_group_uuid ) if isinstance(collider_group_index, int): collider_group_indices.append(collider_group_index) if collider_group_indices: spring_dict["colliderGroups"] = collider_group_indices spring_dicts.append(spring_dict) return spring_dicts @classmethod def search_constraint_target_index( cls, constraint: Union[CopyRotationConstraint, DampedTrackConstraint], object_name_to_index_dict: Mapping[str, int], bone_name_to_index_dict: Mapping[str, int], ) -> Optional[int]: target = constraint.target if not target: return None if target.type == "ARMATURE" and constraint.subtarget: return bone_name_to_index_dict.get(constraint.subtarget) return object_name_to_index_dict.get(target.name) @classmethod def create_constraint_dict( cls, name: str, constraints: search.ExportConstraint, object_name_to_index_dict: Mapping[str, int], bone_name_to_index_dict: Mapping[str, int], ) -> Optional[dict[str, Json]]: roll_constraint = constraints.roll_constraints.get(name) aim_constraint = constraints.aim_constraints.get(name) rotation_constraint = constraints.rotation_constraints.get(name) if roll_constraint: source_index = cls.search_constraint_target_index( roll_constraint, object_name_to_index_dict, bone_name_to_index_dict, ) if isinstance(source_index, int): if roll_constraint.use_x: roll_axis = "X" elif roll_constraint.use_y: roll_axis = "Y" elif roll_constraint.use_z: roll_axis = "Z" else: message = "Unsupported roll axis" raise ValueError(message) return { "roll": { "source": source_index, "rollAxis": roll_axis, "weight": max(0.0, min(1.0, roll_constraint.influence)), } } elif aim_constraint: source_index = cls.search_constraint_target_index( aim_constraint, object_name_to_index_dict, bone_name_to_index_dict, ) if isinstance(source_index, int): return { "aim": { "source": source_index, "aimAxis": convert.BPY_TRACK_AXIS_TO_VRM_AIM_AXIS[ aim_constraint.track_axis ], "weight": max(0.0, min(1.0, aim_constraint.influence)), } } elif rotation_constraint: source_index = cls.search_constraint_target_index( rotation_constraint, object_name_to_index_dict, bone_name_to_index_dict, ) if isinstance(source_index, int): return { "rotation": { "source": source_index, "weight": max(0.0, min(1.0, rotation_constraint.influence)), } } return None @classmethod def create_mtoon0_khr_texture_transform( cls, node: Node, texture_input_name: str ) -> dict[str, Json]: default: dict[str, Json] = { "offset": [0, 0], "scale": [1, 1], } texture_input = node.inputs.get(texture_input_name) if not texture_input: return default texture_input_links = texture_input.links if not texture_input_links: return default x_from_node = texture_input_links[0].from_node if not x_from_node: return default x_inputs = x_from_node.inputs if not x_inputs: return default x_links = x_inputs[0].links if not x_links: return default uv_offset_scaling_node = x_links[0].from_node if not uv_offset_scaling_node or uv_offset_scaling_node.type != "MAPPING'": return default result = default.copy() location_input = uv_offset_scaling_node.inputs.get("Location") if isinstance(location_input, shader.VECTOR_SOCKET_CLASSES): result["offset"] = [ location_input.default_value[0], location_input.default_value[1], ] scale_input = uv_offset_scaling_node.inputs.get("Scale") if isinstance(scale_input, shader.VECTOR_SOCKET_CLASSES): result["scale"] = [ scale_input.default_value[0], scale_input.default_value[1], ] return result @classmethod def find_or_create_image( cls, json_dict: dict[str, Json], buffer0: bytearray, image_name_to_index_dict: dict[str, int], image: Image, gltf2_addon_export_settings: dict[str, object], ) -> int: # TODO: Verify alignment requirement and optimize while len(buffer0) % 32 == 0: buffer0.append(0) image_bytes, mime = image_to_image_bytes(image, gltf2_addon_export_settings) buffer_view_dicts = json_dict.get("bufferViews") if not isinstance(buffer_view_dicts, list): buffer_view_dicts = [] json_dict["bufferViews"] = buffer_view_dicts image_buffer_view_index = len(buffer_view_dicts) buffer_view_dicts.append( { "buffer": 0, "byteOffset": len(buffer0), "byteLength": len(image_bytes), } ) image_index = image_name_to_index_dict.get(image.name) image_dicts = json_dict.get("images") if not isinstance(image_dicts, list): image_dicts = [] json_dict["images"] = image_dicts if isinstance(image_index, int) and not 0 <= image_index < len(image_dicts): logger.error( "Bug: not 0 <= %d < len(images)) for %s", image_index, image.name ) image_index = None if not isinstance(image_index, int): image_index = len(image_dicts) image_dicts.append( { "name": image.name, "bufferView": image_buffer_view_index, "mimeType": mime, } ) image_name_to_index_dict[image.name] = image_index buffer0.extend(image_bytes) # TODO: Verify alignment requirement and optimize while len(buffer0) % 32 == 0: buffer0.append(0) return image_index @classmethod def create_mtoon1_texture_info_dict( cls, json_dict: dict[str, Json], buffer0: bytearray, texture_info: Mtoon1TextureInfoPropertyGroup, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> Optional[dict[str, Json]]: image = texture_info.index.get_connected_node_image() if not image: return None image_index = cls.find_or_create_image( json_dict, buffer0, image_name_to_index_dict, image, gltf2_addon_export_settings, ) mag_filter = Mtoon1SamplerPropertyGroup.mag_filter_enum.identifier_to_value( texture_info.index.sampler.mag_filter, Mtoon1SamplerPropertyGroup.MAG_FILTER_DEFAULT.value, ) min_filter = Mtoon1SamplerPropertyGroup.min_filter_enum.identifier_to_value( texture_info.index.sampler.min_filter, Mtoon1SamplerPropertyGroup.MIN_FILTER_DEFAULT.value, ) wrap_s = Mtoon1SamplerPropertyGroup.wrap_enum.identifier_to_value( texture_info.index.sampler.wrap_s, Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.value, ) wrap_t = Mtoon1SamplerPropertyGroup.wrap_enum.identifier_to_value( texture_info.index.sampler.wrap_t, Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.value, ) sampler_dict: dict[str, Json] = { "magFilter": mag_filter, "minFilter": min_filter, "wrapS": wrap_s, "wrapT": wrap_t, } sampler_dicts = json_dict.get("samplers") if not isinstance(sampler_dicts, list): sampler_dicts = [] json_dict["samplers"] = sampler_dicts if sampler_dict in sampler_dicts: sampler_index = sampler_dicts.index(sampler_dict) else: sampler_index = len(sampler_dicts) sampler_dicts.append(sampler_dict) texture_dicts = json_dict.get("textures") if not isinstance(texture_dicts, list): texture_dicts = [] json_dict["textures"] = texture_dicts texture_dict: dict[str, Json] = { "sampler": sampler_index, "source": image_index, } if texture_dict in texture_dicts: texture_index = texture_dicts.index(texture_dict) else: texture_index = len(texture_dicts) texture_dicts.append(texture_dict) extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_texture_transform" not in extensions_used: extensions_used.append("KHR_texture_transform") json_dict["extensionsUsed"] = extensions_used khr_texture_transform = texture_info.extensions.khr_texture_transform khr_texture_transform_dict: dict[str, Json] = { "offset": list(khr_texture_transform.offset), "scale": list(khr_texture_transform.scale), } return { "index": texture_index, "extensions": {"KHR_texture_transform": khr_texture_transform_dict}, } @classmethod def create_mtoon0_texture_info_dict( cls, context: Context, json_dict: dict[str, Json], buffer0: bytearray, node: Node, texture_input_name: str, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> Optional[dict[str, Json]]: image_name_and_sampler_type = shader.get_image_name_and_sampler_type( node, texture_input_name ) if image_name_and_sampler_type is None: return None image_name, wrap_type, filter_type = image_name_and_sampler_type image_index = cls.find_or_create_image( json_dict, buffer0, image_name_to_index_dict, context.blend_data.images[image_name], gltf2_addon_export_settings, ) sampler_dict: dict[str, Json] = { "magFilter": filter_type, "minFilter": filter_type, "wrapS": wrap_type, "wrapT": wrap_type, } sampler_dicts = json_dict.get("samplers") if not isinstance(sampler_dicts, list): sampler_dicts = [] json_dict["samplers"] = sampler_dicts if sampler_dict in sampler_dicts: sampler_index = sampler_dicts.index(sampler_dict) else: sampler_index = len(sampler_dicts) sampler_dicts.append(sampler_dict) texture_dicts = json_dict.get("textures") if not isinstance(texture_dicts, list): texture_dicts = [] json_dict["textures"] = texture_dicts texture_dict: dict[str, Json] = { "sampler": sampler_index, "source": image_index, } if texture_dict in texture_dicts: texture_index = texture_dicts.index(texture_dict) else: texture_index = len(texture_dicts) texture_dicts.append(texture_dict) extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_texture_transform" not in extensions_used: extensions_used.append("KHR_texture_transform") json_dict["extensionsUsed"] = extensions_used khr_texture_transform_dict = cls.create_mtoon0_khr_texture_transform( node, texture_input_name ) return { "index": texture_index, "extensions": {"KHR_texture_transform": khr_texture_transform_dict}, } @classmethod def create_mtoon1_material_dict( cls, json_dict: dict[str, Json], buffer0: bytearray, material: Material, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> dict[str, Json]: extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_materials_unlit" not in extensions_used: extensions_used.append("KHR_materials_unlit") if "KHR_materials_emissive_strength" not in extensions_used: extensions_used.append("KHR_materials_emissive_strength") if "VRMC_materials_mtoon" not in extensions_used: extensions_used.append("VRMC_materials_mtoon") # https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRM10/Runtime/Migration/Materials/MigrationMToonMaterial.cs mtoon_dict: dict[str, Json] = { "specVersion": "1.0", } gltf = get_material_extension(material).mtoon1 mtoon = gltf.extensions.vrmc_materials_mtoon extensions_dict: dict[str, Json] = { "KHR_materials_unlit": {}, "KHR_materials_emissive_strength": { "emissiveStrength": ( gltf.extensions.khr_materials_emissive_strength.emissive_strength ), }, "VRMC_materials_mtoon": mtoon_dict, } material_dict: dict[str, Json] = {"name": material.name} pbr_metallic_roughness_dict: dict[str, Json] = {} material_dict["alphaMode"] = gltf.alpha_mode mtoon_dict["transparentWithZWrite"] = mtoon.transparent_with_z_write mtoon_dict["renderQueueOffsetNumber"] = mtoon.render_queue_offset_number if gltf.alpha_mode == "MASK": material_dict["alphaCutoff"] = gltf.alpha_cutoff material_dict["doubleSided"] = gltf.double_sided pbr_metallic_roughness_dict["baseColorFactor"] = list( gltf.pbr_metallic_roughness.base_color_factor ) assign_dict( pbr_metallic_roughness_dict, "baseColorTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, gltf.pbr_metallic_roughness.base_color_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) mtoon_dict["shadeColorFactor"] = list(mtoon.shade_color_factor) assign_dict( mtoon_dict, "shadeMultiplyTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.shade_multiply_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) assign_dict( material_dict, "normalTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, gltf.normal_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) normal_texture_dict = material_dict.get("normalTexture") if isinstance(normal_texture_dict, dict): normal_texture_dict["scale"] = gltf.normal_texture.scale mtoon_dict["shadingShiftFactor"] = mtoon.shading_shift_factor assign_dict( mtoon_dict, "shadingShiftTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.shading_shift_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) shading_shift_texture_dict = mtoon_dict.get("shadingShiftTexture") if isinstance(shading_shift_texture_dict, dict): shading_shift_texture_dict["scale"] = mtoon.shading_shift_texture.scale mtoon_dict["shadingToonyFactor"] = mtoon.shading_toony_factor mtoon_dict["giEqualizationFactor"] = mtoon.gi_equalization_factor material_dict["emissiveFactor"] = list(gltf.emissive_factor) assign_dict( material_dict, "emissiveTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, gltf.emissive_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) if assign_dict( mtoon_dict, "matcapTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.matcap_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ): mtoon_dict["matcapFactor"] = list(mtoon.matcap_factor) mtoon_dict["parametricRimColorFactor"] = list(mtoon.parametric_rim_color_factor) mtoon_dict["parametricRimFresnelPowerFactor"] = ( mtoon.parametric_rim_fresnel_power_factor ) mtoon_dict["parametricRimLiftFactor"] = mtoon.parametric_rim_lift_factor assign_dict( mtoon_dict, "rimMultiplyTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.rim_multiply_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) mtoon_dict["rimLightingMixFactor"] = mtoon.rim_lighting_mix_factor mtoon_dict["outlineWidthMode"] = mtoon.outline_width_mode mtoon_dict["outlineWidthFactor"] = mtoon.outline_width_factor assign_dict( mtoon_dict, "outlineWidthMultiplyTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.outline_width_multiply_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) mtoon_dict["outlineColorFactor"] = list(mtoon.outline_color_factor) mtoon_dict["outlineLightingMixFactor"] = mtoon.outline_lighting_mix_factor assign_dict( mtoon_dict, "uvAnimationMaskTexture", cls.create_mtoon1_texture_info_dict( json_dict, buffer0, mtoon.uv_animation_mask_texture, image_name_to_index_dict, gltf2_addon_export_settings, ), ) mtoon_dict["uvAnimationRotationSpeedFactor"] = ( mtoon.uv_animation_rotation_speed_factor ) mtoon_dict["uvAnimationScrollXSpeedFactor"] = ( mtoon.uv_animation_scroll_x_speed_factor ) mtoon_dict["uvAnimationScrollYSpeedFactor"] = ( mtoon.uv_animation_scroll_y_speed_factor ) if pbr_metallic_roughness_dict: material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict extensions_dict["VRMC_materials_mtoon"] = mtoon_dict material_dict["extensions"] = extensions_dict return material_dict @classmethod def create_legacy_gltf_material_dict( cls, context: Context, json_dict: dict[str, Json], buffer0: bytearray, material: Material, node: Node, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> dict[str, Json]: material_dict: dict[str, Json] = {"name": material.name} pbr_metallic_roughness_dict: dict[str, Json] = {} if material.blend_method == "OPAQUE": material_dict["alphaMode"] = "OPAQUE" elif material.blend_method == "CLIP": material_dict["alphaCutoff"] = max(0, min(1, material.alpha_threshold)) material_dict["alphaMode"] = "MASK" else: material_dict["alphaMode"] = "BLEND" assign_dict( material_dict, "doubleSided", not material.use_backface_culling, False ) assign_dict( pbr_metallic_roughness_dict, "baseColorFactor", shader.get_rgba_value(node, "base_Color", 0.0, 1.0), ) base_color_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "color_texture", image_name_to_index_dict, gltf2_addon_export_settings, ) assign_dict( pbr_metallic_roughness_dict, "baseColorTexture", base_color_texture_dict ) assign_dict( pbr_metallic_roughness_dict, "metallicFactor", shader.get_float_value(node, "metallic", 0.0, 1.0), default_value=1.0, ) assign_dict( pbr_metallic_roughness_dict, "roughnessFactor", shader.get_float_value(node, "roughness", 0.0, 1.0), default_value=1.0, ) assign_dict( pbr_metallic_roughness_dict, "metallicRoughnessTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "emissive_texture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) normal_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "normal", image_name_to_index_dict, gltf2_addon_export_settings, ) assign_dict(material_dict, "normalTexture", normal_texture_dict) assign_dict( material_dict, "emissiveFactor", shader.get_rgb_value(node, "emissive_color", 0.0, 1.0), ) assign_dict( material_dict, "emissiveTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "emissive_texture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) assign_dict( material_dict, "occlusionTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "occlusion_texture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) if pbr_metallic_roughness_dict: material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict unlit_value = shader.get_float_value(node, "unlit") if unlit_value is None or unlit_value > 0.5: extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_materials_unlit" not in extensions_used: extensions_used.append("KHR_materials_unlit") material_dict["extensions"] = {"KHR_materials_unlit": {}} return material_dict @classmethod def create_legacy_transparent_zwrite_material_dict( cls, context: Context, json_dict: dict[str, Json], buffer0: bytearray, material: Material, node: Node, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> dict[str, Json]: # https://vrm-c.github.io/UniVRM/en/implementation/transparent_zwrite.html#mtoon-unlit extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_materials_unlit" not in extensions_used: extensions_used.append("KHR_materials_unlit") if "VRMC_materials_mtoon" not in extensions_used: extensions_used.append("VRMC_materials_mtoon") mtoon_dict: dict[str, Json] = { "specVersion": "1.0", } extensions_dict: dict[str, Json] = { "KHR_materials_unlit": {}, "VRMC_materials_mtoon": mtoon_dict, } material_dict: dict[str, Json] = { "name": material.name, "emissiveFactor": [1, 1, 1], } pbr_metallic_roughness_dict: dict[str, Json] = { "baseColorFactor": [0, 0, 0, 1], } material_dict["alphaMode"] = "BLEND" mtoon_dict["transparentWithZWrite"] = True mtoon_dict["renderQueueOffsetNumber"] = 0 assign_dict( material_dict, "doubleSided", not material.use_backface_culling, False ) base_color_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "Main_Texture", image_name_to_index_dict, gltf2_addon_export_settings, ) assign_dict( pbr_metallic_roughness_dict, "baseColorTexture", base_color_texture_dict ) if base_color_texture_dict is not None: mtoon_dict["shadeMultiplyTexture"] = base_color_texture_dict material_dict["emissiveTexture"] = base_color_texture_dict if pbr_metallic_roughness_dict: material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict extensions_dict["VRMC_materials_mtoon"] = mtoon_dict material_dict["extensions"] = extensions_dict return material_dict @classmethod def create_mtoon_unversioned_material_dict( cls, context: Context, json_dict: dict[str, Json], buffer0: bytearray, material: Material, node: Node, image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> dict[str, Json]: extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if "KHR_materials_unlit" not in extensions_used: extensions_used.append("KHR_materials_unlit") if "VRMC_materials_mtoon" not in extensions_used: extensions_used.append("VRMC_materials_mtoon") # https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRM10/Runtime/Migration/Materials/MigrationMToonMaterial.cs mtoon_dict: dict[str, Json] = { "specVersion": "1.0", } extensions_dict: dict[str, Json] = { "KHR_materials_unlit": {}, "VRMC_materials_mtoon": mtoon_dict, } material_dict: dict[str, Json] = {"name": material.name} pbr_metallic_roughness_dict: dict[str, Json] = {} if material.blend_method == "OPAQUE": material_dict["alphaMode"] = "OPAQUE" mtoon_dict["transparentWithZWrite"] = False mtoon_dict["renderQueueOffsetNumber"] = 0 elif material.blend_method == "CLIP": alpha_cutoff = shader.get_float_value(node, "CutoffRate", 0, float_info.max) if alpha_cutoff is not None: material_dict["alphaCutoff"] = alpha_cutoff else: material_dict["alphaCutoff"] = 0.5 material_dict["alphaMode"] = "MASK" mtoon_dict["transparentWithZWrite"] = False mtoon_dict["renderQueueOffsetNumber"] = 0 else: material_dict["alphaMode"] = "BLEND" transparent_with_z_write = shader.get_float_value( node, "TransparentWithZWrite" ) if ( transparent_with_z_write is None or math.fabs(transparent_with_z_write) < float_info.epsilon ): mtoon_dict["transparentWithZWrite"] = False else: mtoon_dict["transparentWithZWrite"] = True mtoon_dict["renderQueueOffsetNumber"] = 0 assign_dict( material_dict, "doubleSided", not material.use_backface_culling, False ) assign_dict( pbr_metallic_roughness_dict, "baseColorFactor", shader.get_rgba_value(node, "DiffuseColor", 0.0, 1.0), ) base_color_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "MainTexture", image_name_to_index_dict, gltf2_addon_export_settings, ) assign_dict( pbr_metallic_roughness_dict, "baseColorTexture", base_color_texture_dict ) assign_dict( mtoon_dict, "shadeColorFactor", shader.get_rgb_value(node, "ShadeColor", 0.0, 1.0), ) shade_multiply_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "ShadeTexture", image_name_to_index_dict, gltf2_addon_export_settings, ) if shade_multiply_texture_dict is not None: mtoon_dict["shadeMultiplyTexture"] = shade_multiply_texture_dict elif base_color_texture_dict is not None: # https://github.com/vrm-c/UniVRM/blob/f3479190c330ec6ecd2b40be919285aa93a53aff/Assets/VRM10/Runtime/Migration/Materials/MigrationMToonMaterial.cs#L185-L204 mtoon_dict["shadeMultiplyTexture"] = base_color_texture_dict normal_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "NormalmapTexture", image_name_to_index_dict, gltf2_addon_export_settings, ) if not normal_texture_dict: normal_texture_dict = cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "NomalmapTexture", image_name_to_index_dict, gltf2_addon_export_settings, ) if assign_dict( material_dict, "normalTexture", normal_texture_dict ) and isinstance(normal_texture_dict, dict): assign_dict( normal_texture_dict, "scale", shader.get_float_value(node, "BumpScale") ) shading_shift_0x = shader.get_float_value(node, "ShadeShift") if shading_shift_0x is None: shading_shift_0x = 0.0 shading_toony_0x = shader.get_float_value(node, "ShadeToony") if shading_toony_0x is None: shading_toony_0x = 0.0 mtoon_dict["shadingShiftFactor"] = convert.mtoon_shading_shift_0_to_1( shading_toony_0x, shading_shift_0x ) mtoon_dict["shadingToonyFactor"] = convert.mtoon_shading_toony_0_to_1( shading_toony_0x, shading_shift_0x ) gi_equalization_0x = shader.get_float_value(node, "IndirectLightIntensity") if gi_equalization_0x is not None: mtoon_dict["giEqualizationFactor"] = ( convert.mtoon_intensity_to_gi_equalization(gi_equalization_0x) ) assign_dict( material_dict, "emissiveFactor", shader.get_rgb_value(node, "EmissionColor", 0.0, 1.0), ) assign_dict( material_dict, "emissiveTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "Emission_Texture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) if assign_dict( mtoon_dict, "matcapTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "SphereAddTexture", image_name_to_index_dict, gltf2_addon_export_settings, ), ): mtoon_dict["matcapFactor"] = [1, 1, 1] assign_dict( mtoon_dict, "parametricRimColorFactor", shader.get_rgb_value(node, "RimColor", 0.0, 1.0), ) assign_dict( mtoon_dict, "parametricRimFresnelPowerFactor", shader.get_float_value(node, "RimFresnelPower", 0.0, float_info.max), ) assign_dict( mtoon_dict, "parametricRimLiftFactor", shader.get_float_value(node, "RimLift"), ) assign_dict( mtoon_dict, "rimMultiplyTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "RimTexture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) # https://github.com/vrm-c/UniVRM/blob/7c9919ef47a25c04100a2dcbe6a75dff49ef4857/Assets/VRM10/Runtime/Migration/Materials/MigrationMToonMaterial.cs#L287-L290 mtoon_dict["rimLightingMixFactor"] = 1.0 centimeter_to_meter = 0.01 one_hundredth = 0.01 outline_width_mode = shader.get_float_value(node, "OutlineWidthMode") if outline_width_mode is not None: outline_width_mode = round(outline_width_mode) else: outline_width_mode = 0 outline_width = shader.get_float_value(node, "OutlineWidth") if outline_width is None: outline_width = 0.0 if outline_width_mode == 1: mtoon_dict["outlineWidthMode"] = "worldCoordinates" mtoon_dict["outlineWidthFactor"] = max( 0.0, outline_width * centimeter_to_meter ) elif outline_width_mode == 2: mtoon_dict["outlineWidthMode"] = "screenCoordinates" mtoon_dict["outlineWidthFactor"] = max( 0.0, outline_width * one_hundredth * 0.5 ) else: mtoon_dict["outlineWidthMode"] = "none" assign_dict( mtoon_dict, "outlineWidthMultiplyTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "OutlineWidthTexture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) assign_dict( mtoon_dict, "outlineColorFactor", shader.get_rgb_value(node, "OutlineColor", 0.0, 1.0), ) outline_color_mode = shader.get_float_value(node, "OutlineColorMode") if outline_color_mode is not None: outline_color_mode = round(outline_color_mode) else: outline_color_mode = 0 mtoon_dict["outlineLightingMixFactor"] = 0.0 if outline_color_mode == 1: assign_dict( mtoon_dict, "outlineLightingMixFactor", shader.get_float_value(node, "OutlineLightingMix"), ) assign_dict( mtoon_dict, "uvAnimationMaskTexture", cls.create_mtoon0_texture_info_dict( context, json_dict, buffer0, node, "UV_Animation_Mask_Texture", image_name_to_index_dict, gltf2_addon_export_settings, ), ) assign_dict( mtoon_dict, "uvAnimationRotationSpeedFactor", shader.get_float_value(node, "UV_Scroll_Rotation"), ) assign_dict( mtoon_dict, "uvAnimationScrollXSpeedFactor", shader.get_float_value(node, "UV_Scroll_X"), ) invert_y = -1 uv_animation_scroll_y_speed_factor = shader.get_float_value(node, "UV_Scroll_Y") if uv_animation_scroll_y_speed_factor is not None: mtoon_dict["uvAnimationScrollYSpeedFactor"] = ( uv_animation_scroll_y_speed_factor * invert_y ) if pbr_metallic_roughness_dict: material_dict["pbrMetallicRoughness"] = pbr_metallic_roughness_dict extensions_dict["VRMC_materials_mtoon"] = mtoon_dict material_dict["extensions"] = extensions_dict return material_dict @classmethod def save_vrm_materials( cls, context: Context, json_dict: dict[str, Json], buffer0: bytearray, material_name_to_index_dict: Mapping[str, int], image_name_to_index_dict: dict[str, int], gltf2_addon_export_settings: dict[str, object], ) -> None: material_dicts = json_dict.get("materials") if not isinstance(material_dicts, list): material_dicts = [] json_dict["materials"] = material_dicts for material_name, index in material_name_to_index_dict.items(): material = context.blend_data.materials.get(material_name) if not isinstance(material, Material) or not ( 0 <= index < len(material_dicts) ): continue if get_material_extension(material).mtoon1.enabled: material_dicts[index] = cls.create_mtoon1_material_dict( json_dict, buffer0, material, image_name_to_index_dict, gltf2_addon_export_settings, ) continue # MToon_unversioned (MToon for VRM 0.0) node, legacy_shader_name = search.legacy_shader_node(material) if not isinstance(node, Node): continue if legacy_shader_name == "MToon_unversioned": material_dicts[index] = cls.create_mtoon_unversioned_material_dict( context, json_dict, buffer0, material, node, image_name_to_index_dict, gltf2_addon_export_settings, ) elif legacy_shader_name == "GLTF": material_dicts[index] = cls.create_legacy_gltf_material_dict( context, json_dict, buffer0, material, node, image_name_to_index_dict, gltf2_addon_export_settings, ) elif legacy_shader_name == "TRANSPARENT_ZWRITE": material_dicts[index] = ( cls.create_legacy_transparent_zwrite_material_dict( context, json_dict, buffer0, material, node, image_name_to_index_dict, gltf2_addon_export_settings, ) ) if material_dicts: json_dict["materials"] = material_dicts @staticmethod def enter_disable_mtoon1_material_nodes(context: Context) -> Sequence[str]: disabled_material_names: list[str] = [] for material in context.blend_data.materials: if not material: continue if not get_material_extension(material).mtoon1.enabled: continue link_or_unlink_gltf_material_nodes(context, material, link=False) disabled_material_names.append(material.name) return disabled_material_names @staticmethod def leave_disable_mtoon1_material_nodes( context: Context, disabled_material_names: Sequence[str] ) -> None: for disabled_material_name in disabled_material_names: disabled_material = context.blend_data.materials.get(disabled_material_name) if not disabled_material: continue link_or_unlink_gltf_material_nodes(context, disabled_material, link=True) @classmethod @contextmanager def disable_mtoon1_material_nodes(cls, context: Context) -> Iterator[None]: disabled_material_names = cls.enter_disable_mtoon1_material_nodes(context) try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: cls.leave_disable_mtoon1_material_nodes(context, disabled_material_names) @classmethod def unassign_normal_from_mtoon_primitive_morph_target( cls, context: Context, json_dict: dict[str, Json], material_name_to_index_dict: Mapping[str, int], ) -> None: mesh_dicts = json_dict.get("meshes") if not isinstance(mesh_dicts, list): return for mesh_dict in mesh_dicts: if not isinstance(mesh_dict, dict): continue primitive_dicts = mesh_dict.get("primitives") if not isinstance(primitive_dicts, list): continue for primitive_dict in primitive_dicts: if not isinstance(primitive_dict, dict): continue material_index = primitive_dict.get("material") if not isinstance(material_index, int): continue skip = True for ( search_material_name, search_material_index, ) in material_name_to_index_dict.items(): if material_index != search_material_index: continue material = context.blend_data.materials.get(search_material_name) if not material: continue if get_material_extension(material).mtoon1.export_shape_key_normals: continue if get_material_extension(material).mtoon1.enabled: skip = False break node, legacy_shader_name = search.legacy_shader_node(material) if not node: continue if legacy_shader_name == "MToon_unversioned": skip = False break if skip: continue target_dicts = primitive_dict.get("targets") if not isinstance(target_dicts, list): continue for target_dict in target_dicts: if not isinstance(target_dict, dict): continue target_dict.pop("NORMAL", None) @staticmethod def enter_setup_dummy_human_bones( context: Context, armature_object: Object ) -> Optional[Mapping[HumanBoneName, str]]: armature_data = armature_object.data if not isinstance(armature_data, Armature): return None ext = get_armature_extension(armature_data) human_bones = ext.vrm1.humanoid.human_bones if human_bones.all_required_bones_are_assigned(): return None human_bone_name_to_bone_name: dict[HumanBoneName, str] = {} for ( human_bone_name, human_bone, ) in human_bones.human_bone_name_to_human_bone().items(): if not human_bone.node.bone_name: continue human_bone_name_to_bone_name[human_bone_name] = human_bone.node.bone_name human_bone.node.bone_name = "" with save_workspace(context, armature_object, mode="EDIT"): hips_bone = armature_data.edit_bones.new(HumanBoneName.HIPS.value) hips_bone.head = Vector((0, 0, 0.5)) hips_bone.tail = Vector((0, 1, 0.5)) hips_bone_name = deepcopy(hips_bone.name) right_upper_leg_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_UPPER_LEG.value ) right_upper_leg_bone.head = Vector((-0.125, 0, 0.5)) right_upper_leg_bone.tail = Vector((-0.125, 1, 0.5)) right_upper_leg_bone.parent = hips_bone right_upper_leg_bone_name = deepcopy(right_upper_leg_bone.name) right_lower_leg_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_LOWER_LEG.value ) right_lower_leg_bone.head = Vector((-0.125, 0, 0.25)) right_lower_leg_bone.tail = Vector((-0.125, 1, 0.25)) right_lower_leg_bone.parent = right_upper_leg_bone right_lower_leg_bone_name = deepcopy(right_lower_leg_bone.name) right_foot_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_FOOT.value ) right_foot_bone.head = Vector((-0.125, 0, 0)) right_foot_bone.tail = Vector((-0.125, 1, 0)) right_foot_bone.parent = right_lower_leg_bone right_foot_bone_name = deepcopy(right_foot_bone.name) left_upper_leg_bone = armature_data.edit_bones.new( HumanBoneName.LEFT_UPPER_LEG.value ) left_upper_leg_bone.head = Vector((0.125, 0, 0.5)) left_upper_leg_bone.tail = Vector((0.125, 1, 0.5)) left_upper_leg_bone.parent = hips_bone left_upper_leg_bone_name = deepcopy(left_upper_leg_bone.name) left_lower_leg_bone = armature_data.edit_bones.new( HumanBoneName.LEFT_LOWER_LEG.value ) left_lower_leg_bone.head = Vector((0.125, 0, 0.25)) left_lower_leg_bone.tail = Vector((0.125, 1, 0.25)) left_lower_leg_bone.parent = left_upper_leg_bone left_lower_leg_bone_name = deepcopy(left_lower_leg_bone.name) left_foot_bone = armature_data.edit_bones.new(HumanBoneName.LEFT_FOOT.value) left_foot_bone.head = Vector((0.125, 0, 0)) left_foot_bone.tail = Vector((0.125, 1, 0)) left_foot_bone.parent = left_lower_leg_bone left_foot_bone_name = deepcopy(left_foot_bone.name) spine_bone = armature_data.edit_bones.new(HumanBoneName.SPINE.value) spine_bone.head = Vector((0, 0, 0.625)) spine_bone.tail = Vector((0, 1, 0.625)) spine_bone.parent = hips_bone spine_bone_name = deepcopy(spine_bone.name) right_upper_arm_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_UPPER_ARM.value ) right_upper_arm_bone.head = Vector((-0.125, 0, 0.75)) right_upper_arm_bone.tail = Vector((-0.125, 1, 0.75)) right_upper_arm_bone.parent = spine_bone right_upper_arm_bone_name = deepcopy(right_upper_arm_bone.name) right_lower_arm_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_LOWER_ARM.value ) right_lower_arm_bone.head = Vector((-0.25, 0, 0.75)) right_lower_arm_bone.tail = Vector((-0.25, 1, 0.75)) right_lower_arm_bone.parent = right_upper_arm_bone right_lower_arm_bone_name = deepcopy(right_lower_arm_bone.name) right_hand_bone = armature_data.edit_bones.new( HumanBoneName.RIGHT_HAND.value ) right_hand_bone.head = Vector((-0.375, 0, 0.75)) right_hand_bone.tail = Vector((-0.375, 1, 0.75)) right_hand_bone.parent = right_lower_arm_bone right_hand_bone_name = deepcopy(right_hand_bone.name) left_upper_arm_bone = armature_data.edit_bones.new( HumanBoneName.LEFT_UPPER_ARM.value ) left_upper_arm_bone.head = Vector((0.125, 0, 0.75)) left_upper_arm_bone.tail = Vector((0.125, 1, 0.75)) left_upper_arm_bone.parent = spine_bone left_upper_arm_bone_name = deepcopy(left_upper_arm_bone.name) left_lower_arm_bone = armature_data.edit_bones.new( HumanBoneName.LEFT_LOWER_ARM.value ) left_lower_arm_bone.head = Vector((0.25, 0, 0.75)) left_lower_arm_bone.tail = Vector((0.25, 1, 0.75)) left_lower_arm_bone.parent = left_upper_arm_bone left_lower_arm_bone_name = deepcopy(left_lower_arm_bone.name) left_hand_bone = armature_data.edit_bones.new(HumanBoneName.LEFT_HAND.value) left_hand_bone.head = Vector((0.375, 0, 0.75)) left_hand_bone.tail = Vector((0.375, 1, 0.75)) left_hand_bone.parent = left_lower_arm_bone left_hand_bone_name = deepcopy(left_hand_bone.name) head_bone = armature_data.edit_bones.new(HumanBoneName.HEAD.value) head_bone.head = Vector((0, 0, 0.75)) head_bone.tail = Vector((0, 1, 0.75)) head_bone.parent = spine_bone head_bone_name = deepcopy(head_bone.name) Vrm1HumanBonesPropertyGroup.update_all_node_candidates( context, armature_data.name ) human_bones.head.node.bone_name = head_bone_name human_bones.spine.node.bone_name = spine_bone_name human_bones.hips.node.bone_name = hips_bone_name human_bones.right_upper_arm.node.bone_name = right_upper_arm_bone_name human_bones.right_lower_arm.node.bone_name = right_lower_arm_bone_name human_bones.right_hand.node.bone_name = right_hand_bone_name human_bones.left_upper_arm.node.bone_name = left_upper_arm_bone_name human_bones.left_lower_arm.node.bone_name = left_lower_arm_bone_name human_bones.left_hand.node.bone_name = left_hand_bone_name human_bones.right_upper_leg.node.bone_name = right_upper_leg_bone_name human_bones.right_lower_leg.node.bone_name = right_lower_leg_bone_name human_bones.right_foot.node.bone_name = right_foot_bone_name human_bones.left_upper_leg.node.bone_name = left_upper_leg_bone_name human_bones.left_lower_leg.node.bone_name = left_lower_leg_bone_name human_bones.left_foot.node.bone_name = left_foot_bone_name return human_bone_name_to_bone_name @staticmethod def leave_setup_dummy_human_bones( context: Context, armature_object: Object, human_bone_name_to_bone_name: Mapping[HumanBoneName, str], ) -> None: armature_data = armature_object.data if not isinstance(armature_data, Armature): return human_bones = get_armature_extension(armature_data).vrm1.humanoid.human_bones human_bone_name_to_human_bone = human_bones.human_bone_name_to_human_bone() dummy_bone_names = deepcopy( [ human_bone.node.bone_name for human_bone in human_bone_name_to_human_bone.values() if human_bone.node.bone_name ] ) for human_bone_name, human_bone in human_bone_name_to_human_bone.items(): bone_name = human_bone_name_to_bone_name.get(human_bone_name) if bone_name: human_bone.node.bone_name = bone_name else: human_bone.node.bone_name = "" Vrm1HumanBonesPropertyGroup.update_all_node_candidates( context, armature_data.name ) with save_workspace(context, armature_object, mode="EDIT"): for dummy_bone_name in dummy_bone_names: dummy_edit_bone = armature_data.edit_bones.get(dummy_bone_name) if dummy_edit_bone: armature_data.edit_bones.remove(dummy_edit_bone) @classmethod @contextmanager def setup_dummy_human_bones( cls, context: Context, armature: Object, ) -> Iterator[None]: human_bone_name_to_bone_name = cls.enter_setup_dummy_human_bones( context, armature, ) try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: if human_bone_name_to_bone_name is not None: cls.leave_setup_dummy_human_bones( context, armature, human_bone_name_to_bone_name, ) @staticmethod def clear_constrainted_rotation( constraint: CopyRotationConstraint, object_or_bone: Union[Object, PoseBone] ) -> None: euler_order = constraint.euler_order if euler_order == "AUTO": if object_or_bone.rotation_mode in ROTATION_MODE_EULER: euler_order = object_or_bone.rotation_mode else: euler_order = "XYZ" euler = get_rotation_as_quaternion(object_or_bone).to_euler(euler_order) if constraint.use_x: euler.x = 0 if constraint.use_y: euler.y = 0 if constraint.use_z: euler.z = 0 set_rotation_without_mode_change(object_or_bone, euler.to_quaternion()) @staticmethod def enter_disable_constraints( context: Context, armature: Object, export_objects: Sequence[Object] ) -> tuple[Sequence[tuple[str, str]], Sequence[tuple[str, str]]]: constraint: Optional[Constraint] = None object_constraints, bone_constraints, _ = search.export_constraints( export_objects, armature ) object_name_and_constraint_name: list[tuple[str, str]] = [] for object_name, constraint in object_constraints.all_constraints: obj = context.blend_data.objects.get(object_name) if not obj: continue if isinstance(constraint, CopyRotationConstraint): Vrm1Exporter.clear_constrainted_rotation(constraint, obj) else: set_rotation_without_mode_change(obj, Quaternion()) constraint.mute = True object_name_and_constraint_name.append((object_name, constraint.name)) bone_name_and_constraint_name: list[tuple[str, str]] = [] for bone_name, constraint in bone_constraints.all_constraints: bone = armature.pose.bones.get(bone_name) if not bone: continue if isinstance(constraint, CopyRotationConstraint): Vrm1Exporter.clear_constrainted_rotation(constraint, bone) else: set_rotation_without_mode_change(bone, Quaternion()) constraint.mute = True bone_name_and_constraint_name.append((bone_name, constraint.name)) return (object_name_and_constraint_name, bone_name_and_constraint_name) @staticmethod def leave_disable_constraints( context: Context, armature: Object, object_name_and_constraint_name: Sequence[tuple[str, str]], bone_name_and_constraint_name: Sequence[tuple[str, str]], ) -> None: # Consider the possibility that Object or Constraint may have been # deleted, and retrieve them again for object_name, constraint_name in object_name_and_constraint_name: obj = context.blend_data.objects.get(object_name) if not obj: continue constraint = obj.constraints.get(constraint_name) if not constraint: continue constraint.mute = False for bone_name, constraint_name in bone_name_and_constraint_name: bone = armature.pose.bones.get(bone_name) if not bone: continue constraint = bone.constraints.get(constraint_name) if not constraint: continue constraint.mute = False @contextmanager def disable_constraints(self, context: Context) -> Iterator[None]: object_name_and_constraint_name, bone_name_and_constraint_name = ( self.enter_disable_constraints(context, self.armature, self.export_objects) ) try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: self.leave_disable_constraints( context, self.armature, object_name_and_constraint_name, bone_name_and_constraint_name, ) def enter_assign_export_custom_properties(self) -> None: self.armature[self.extras_main_armature_key] = True armature_data = self.armature.data if not isinstance(armature_data, Armature): return # To minimize the impact of other glTF2ExportUserExtensions, # use custom properties that seem to have less impact # to establish correspondence between Blender objects and indices. for obj in self.context.blend_data.objects: obj[self.extras_object_name_key] = obj.name for material in self.context.blend_data.materials: material[self.extras_material_name_key] = material.name # The glTF 2.0 addon comment states that it saves custom properties with # PoseBone, but it actually references custom properties of Bone. # Therefore, write to both just in case for pose_bone in self.armature.pose.bones: pose_bone[self.extras_bone_name_key] = pose_bone.name for bone in armature_data.bones: bone[self.extras_bone_name_key] = bone.name def leave_assign_export_custom_properties(self) -> None: armature_data = self.armature.data if not isinstance(armature_data, Armature): return for pose_bone in self.armature.pose.bones: pose_bone.pop(self.extras_bone_name_key, None) for bone in armature_data.bones: bone.pop(self.extras_bone_name_key, None) for material in self.context.blend_data.materials: material.pop(self.extras_material_name_key, None) for obj in self.context.blend_data.objects: obj.pop(self.extras_object_name_key, None) self.armature.pop(self.extras_main_armature_key, None) @contextmanager def assign_export_custom_properties(self) -> Iterator[None]: self.enter_assign_export_custom_properties() try: yield # After yield, bpy's native objects may be deleted or frames may advance # and become invalid. Accessing them in that state can cause crashes, so # be careful not to access such potentially invalid native objects # after yield finally: self.leave_assign_export_custom_properties() @staticmethod def gltf_export_armature_object_remove( context: Context, mesh_object_names: Sequence[str] ) -> bool: """Return whether to enable export_armature_object_remove. export_armature_object_remove is very useful and we'd like to use it, but it has many bugs, so enable it only when not affected by those bugs. """ if bpy.app.version < (4, 2): return False if bpy.app.version < (4, 4): # Root bone with non-deform bones returns False # https://github.com/KhronosGroup/glTF-Blender-IO/issues/2394 for selected_object in context.selected_objects: if selected_object.type != "ARMATURE": continue armature = selected_object.data if not isinstance(armature, Armature): continue for bone in armature.bones: if bone.parent: continue if not bone.use_deform: return False # Return False if there are vertices with Armature modifier but no weights # https://github.com/KhronosGroup/glTF-Blender-IO/issues/2436 armature_data_name_to_deform_bone_names: dict[str, set[str]] = {} for mesh_object_name in mesh_object_names: mesh_object = context.blend_data.objects.get(mesh_object_name) if not mesh_object: continue if mesh_object.type != "MESH": continue mesh = mesh_object.data if not isinstance(mesh, Mesh): continue vertex_group_names_sequence: Sequence[set[str]] = [ { mesh_object.vertex_groups[group.group].name for group in vertex.groups if group.weight > 0 and 0 <= group.group < len(mesh_object.vertex_groups) } for vertex in mesh.vertices ] for modifier in mesh_object.modifiers: if modifier.type != "ARMATURE": continue if not isinstance(modifier, ArmatureModifier): continue if not modifier.show_viewport: continue armature_object = modifier.object if not armature_object: continue armature_data = armature_object.data if not isinstance(armature_data, Armature): continue deform_bone_names = armature_data_name_to_deform_bone_names.get( armature_data.name ) if deform_bone_names is None: deform_bone_names = { bone.name for bone in armature_data.bones if bone.use_deform } armature_data_name_to_deform_bone_names[armature_data.name] = ( deform_bone_names ) for vertex_group_names in vertex_group_names_sequence: if all( vertex_group_name not in deform_bone_names for vertex_group_name in vertex_group_names ): return False return True def export_vrm(self) -> Optional[bytes]: init_extras_export() armature_data = self.armature.data if not isinstance(armature_data, Armature): message = f"{type(armature_data)} is not an Armature" raise TypeError(message) self.setup_mtoon_gltf_fallback_nodes(self.context, is_vrm0=False) with ( save_workspace(self.context), self.setup_dummy_human_bones(self.context, self.armature), self.clear_blend_shape_proxy_previews(armature_data), self.enable_deform_for_all_referenced_bones(armature_data), setup_humanoid_t_pose(self.context, self.armature), self.overwrite_object_visibility_and_selection(), ): with ( self.disable_constraints(self.context), self.hide_mtoon1_outline_geometry_nodes(self.context), self.disable_mtoon1_material_nodes(self.context), self.mount_skinned_mesh_parent(), self.save_selected_mesh_compat_objects() as mesh_compat_object_names, self.assign_export_custom_properties(), tempfile.TemporaryDirectory() as temp_dir, ): force_apply_modifiers_to_objects( self.context, self.armature, mesh_compat_object_names ) filepath = Path(temp_dir, "out.glb") export_scene_gltf_result = export_scene_gltf( ExportSceneGltfArguments( filepath=str(filepath), check_existing=False, export_format="GLB", export_extras=True, export_def_bones=True, export_current_frame=True, use_selection=True, use_active_scene=True, export_animations=self.export_gltf_animations, export_armature_object_remove=( self.gltf_export_armature_object_remove( self.context, mesh_compat_object_names ) ), export_rest_position_armature=False, export_apply=False, # Models may appear incorrectly in many viewers export_all_influences=self.export_all_influences, # TODO: Expose UI Option, Unity allows light export export_lights=self.export_lights, export_try_sparse_sk=self.export_try_sparse_sk, export_vertex_color="MATERIAL", ) ) if export_scene_gltf_result == {"CANCELLED"}: return None if export_scene_gltf_result != {"FINISHED"}: message = ( 'The glTF2 Exporter has not been {"FINISHED"}' f" but {export_scene_gltf_result}" ) raise AssertionError(message) extra_name_assigned_glb = filepath.read_bytes() vrm_bytes = self.add_vrm_extension_to_glb(extra_name_assigned_glb) if vrm_bytes is None: return None logger.info("Generated VRM size: %s bytes", len(vrm_bytes)) return vrm_bytes def remove_exported_armature_object_before_4_2( self, json_dict: dict[str, Json], node_dicts: list[Json], armature_node_index: int, ) -> None: """Delete scene armature object if it can be deleted in Blender < 4.2. This is to prevent the Armature object from being treated as a bone when re-importing in Blender. TODO: Should be handled more intelligently using skin.skeleton etc. When the main armature has transforms, the current method doesn't work well with Blender 4.2.1, UniVRM 0.126.0, etc., so we don't do it. When there are multiple root bones with Skin Joints registered, skin.joints requires a common root bone constraint, so we don't delete it. """ if bpy.app.version >= (4, 2): return armature_world_matrix = ( find_node_world_matrix(node_dicts, armature_node_index, None) or Matrix() ) if not (0 <= armature_node_index < len(node_dicts)): return armature_node_dict = node_dicts[armature_node_index] if not isinstance(armature_node_dict, dict): return if not is_identity_matrix(armature_world_matrix): return scene_dicts = json_dict.get("scenes") if not isinstance(scene_dicts, list): return all_joint_node_indices: list[Sequence[int]] = [] skin_dicts = json_dict.get("skins") if isinstance(skin_dicts, list): for skin_dict in skin_dicts: if not isinstance(skin_dict, dict): continue joints = skin_dict.get("joints") if not isinstance(joints, list): continue all_joint_node_indices.append( [ joint_node_index for joint_node_index in joints if isinstance(joint_node_index, int) ] ) armature_child_indices = ( [child for child in children if isinstance(child, int)] if isinstance(children := armature_node_dict.get("children"), Sequence) else [] ) for joint_node_indices in all_joint_node_indices: if len(set(joint_node_indices).intersection(armature_child_indices)) >= 2: return armature_replaced = False for scene_dict in scene_dicts: if not isinstance(scene_dict, dict): continue scene_node_indices = scene_dict.get("nodes") if not isinstance(scene_node_indices, list): continue # Remove ancestor nodes of that armature among nodes belonging to the scene for scene_node_index in list(scene_node_indices): if not isinstance(scene_node_index, int): continue search_scene_node_indices = [scene_node_index] while search_scene_node_indices: search_scene_node_index = search_scene_node_indices.pop() if search_scene_node_index == armature_node_index: scene_node_indices.remove(scene_node_index) break if not 0 <= search_scene_node_index < len(node_dicts): continue search_scene_node_dict = node_dicts[search_scene_node_index] if not isinstance(search_scene_node_dict, dict): continue child_indices = search_scene_node_dict.get("children") if not isinstance(child_indices, list): continue for child_index in child_indices: if not isinstance(child_index, int): continue search_scene_node_indices.append(child_index) for armature_child_index in armature_child_indices: if ( not 0 <= armature_child_index < len(node_dicts) or armature_child_index in scene_node_indices ): continue scene_node_indices.append(armature_child_index) if armature_replaced: continue # Apply world matrix up to main armature to its children child_node_dict = node_dicts[armature_child_index] if not isinstance(child_node_dict, dict): continue child_matrix = get_node_matrix(child_node_dict) set_node_matrix( child_node_dict, armature_world_matrix @ child_matrix, ) armature_replaced = True if armature_replaced: armature_node_dict.pop("children", None) armature_node_dict["name"] = "secondary" # Assign dummy name def add_vrm_extension_to_glb( self, extra_name_assigned_glb: bytes ) -> Optional[bytes]: armature_data = self.armature.data if not isinstance(armature_data, Armature): message = f"{type(armature_data)} is not an Armature" raise TypeError(message) vrm = get_armature_extension(armature_data).vrm1 json_dict, buffer0_bytes = parse_glb(extra_name_assigned_glb) buffer0 = bytearray(buffer0_bytes) bone_name_to_index_dict: dict[str, int] = {} object_name_to_index_dict: dict[str, int] = {} image_name_to_index_dict: dict[str, int] = {} mesh_object_name_to_node_index_dict: dict[str, int] = {} mesh_object_name_to_morph_target_names_dict: dict[str, list[str]] = {} node_dicts = json_dict.get("nodes") if not isinstance(node_dicts, list): node_dicts = [] json_dict["nodes"] = node_dicts for node_index, node_dict in enumerate(node_dicts): if not isinstance(node_dict, dict): continue extras_dict = node_dict.get("extras") if not isinstance(extras_dict, dict): continue bone_name = extras_dict.pop(self.extras_bone_name_key, None) if isinstance(bone_name, str): bone_name_to_index_dict[bone_name] = node_index is_main_armature = extras_dict.pop(self.extras_main_armature_key, None) object_name = extras_dict.pop(self.extras_object_name_key, None) if isinstance(object_name, str): object_name_to_index_dict[object_name] = node_index if bpy.app.version < (3, 3): is_main_armature = object_name == self.armature.name if is_main_armature: if not extras_dict: node_dict.pop("extras", None) self.remove_exported_armature_object_before_4_2( json_dict, node_dicts, node_index ) mesh_index = node_dict.get("mesh") mesh_dicts = json_dict.get("meshes") if ( isinstance(object_name, str) and isinstance(mesh_index, int) and isinstance(mesh_dicts, list) and 0 <= mesh_index < len(mesh_dicts) ): mesh_object_name_to_node_index_dict[object_name] = node_index mesh_dict = mesh_dicts[mesh_index] if isinstance(mesh_dict, dict): mesh_extras_dict = mesh_dict.get("extras") if isinstance(mesh_extras_dict, dict): target_names = mesh_extras_dict.get("targetNames") if isinstance(target_names, list): mesh_object_name_to_morph_target_names_dict[object_name] = [ str(target_name) for target_name in target_names ] if isinstance(object_name, str) and ( object_name.startswith(INTERNAL_NAME_PREFIX + "VrmAddonLinkTo") # or object_name == dummy_skinned_mesh_object_name ): node_dict.clear() for child_removing_node_dict in node_dicts: if not isinstance(child_removing_node_dict, dict): continue children = child_removing_node_dict.get("children") if not isinstance(children, list): continue children = [child for child in children if child != node_index] if children: child_removing_node_dict["children"] = children else: child_removing_node_dict.pop("children", None) # TODO: remove from scenes, skin joints ... if not extras_dict and "extras" in node_dict: node_dict.pop("extras", None) node_constraint_spec_version = "1.0" use_node_constraint = False object_constraints, bone_constraints, _ = search.export_constraints( self.export_objects, self.armature ) for object_name, node_index in object_name_to_index_dict.items(): if not 0 <= node_index < len(node_dicts): continue node_dict = node_dicts[node_index] if not isinstance(node_dict, dict): logger.error( "gltf.nodes[%d] is invalid value: %s", node_index, node_dict ) node_dict = {} node_dicts[node_index] = node_dict constraint_dict = self.create_constraint_dict( object_name, object_constraints, object_name_to_index_dict, bone_name_to_index_dict, ) if constraint_dict: extensions = node_dict.get("extensions") if not isinstance(extensions, dict): node_dict["extensions"] = extensions = {} extensions["VRMC_node_constraint"] = { "specVersion": node_constraint_spec_version, "constraint": constraint_dict, } use_node_constraint = True for bone_name, node_index in bone_name_to_index_dict.items(): if not 0 <= node_index < len(node_dicts): continue node_dict = node_dicts[node_index] if not isinstance(node_dict, dict): logger.error( "gltf.nodes[%d] is invalid value: %s", node_index, node_dict ) node_dict = {} node_dicts[node_index] = node_dicts constraint_dict = self.create_constraint_dict( bone_name, bone_constraints, object_name_to_index_dict, bone_name_to_index_dict, ) if constraint_dict: extensions = node_dict.get("extensions") if not isinstance(extensions, dict): node_dict["extensions"] = extensions = {} extensions["VRMC_node_constraint"] = { "specVersion": node_constraint_spec_version, "constraint": constraint_dict, } use_node_constraint = True material_name_to_index_dict: dict[str, int] = {} material_dicts = json_dict.get("materials") if not isinstance(material_dicts, list): material_dicts = [] json_dict["materials"] = material_dicts for material_index, material_dict in enumerate(material_dicts): if not isinstance(material_dict, dict): continue extras_dict = material_dict.get("extras") if not isinstance(extras_dict, dict): continue material_name = extras_dict.pop(self.extras_material_name_key, None) if not isinstance(material_name, str): continue material_name_to_index_dict[material_name] = material_index if not extras_dict: material_dict.pop("extras", None) self.save_vrm_materials( self.context, json_dict, buffer0, material_name_to_index_dict, image_name_to_index_dict, self.gltf2_addon_export_settings, ) self.unassign_normal_from_mtoon_primitive_morph_target( self.context, json_dict, material_name_to_index_dict ) extensions_used = json_dict.get("extensionsUsed") if not isinstance(extensions_used, list): extensions_used = [] json_dict["extensionsUsed"] = extensions_used if use_node_constraint: extensions_used.append("VRMC_node_constraint") extensions = json_dict.get("extensions") if not isinstance(extensions, dict): json_dict["extensions"] = extensions = {} extensions_used.append("VRMC_vrm") extensions["VRMC_vrm"] = { "specVersion": get_armature_extension(armature_data).spec_version, "meta": self.create_meta_dict( vrm.meta, json_dict, buffer0, image_name_to_index_dict, self.gltf2_addon_export_settings, ), "humanoid": self.create_humanoid_dict( vrm.humanoid, bone_name_to_index_dict ), "firstPerson": self.create_first_person_dict( vrm.first_person, mesh_object_name_to_node_index_dict ), "lookAt": self.create_look_at_dict(vrm.look_at), "expressions": self.create_expressions_dict( vrm.expressions, mesh_object_name_to_node_index_dict, mesh_object_name_to_morph_target_names_dict, material_name_to_index_dict, ), } spring_bone = get_armature_extension(armature_data).spring_bone1 spring_bone_dict: dict[str, Json] = {} ( spring_bone_collider_dicts, collider_uuid_to_index_dict, ) = self.create_spring_bone_collider_dicts( extensions_used, spring_bone, bone_name_to_index_dict ) if spring_bone_collider_dicts: spring_bone_dict["colliders"] = spring_bone_collider_dicts ( spring_bone_collider_group_dicts, collider_group_uuid_to_index_dict, ) = self.create_spring_bone_collider_group_dicts( spring_bone, collider_uuid_to_index_dict ) if spring_bone_collider_group_dicts: spring_bone_dict["colliderGroups"] = spring_bone_collider_group_dicts spring_bone_spring_dicts = self.create_spring_bone_spring_dicts( spring_bone, bone_name_to_index_dict, collider_group_uuid_to_index_dict, self.armature, ) if spring_bone_spring_dicts: spring_bone_dict["springs"] = spring_bone_spring_dicts if spring_bone_dict: extensions_used.append("VRMC_springBone") spring_bone_dict["specVersion"] = "1.0" extensions["VRMC_springBone"] = spring_bone_dict json_dict["extensions"] = extensions json_dict["extensionsUsed"] = extensions_used v = get_addon_version() if environ.get("BLENDER_VRM_USE_TEST_EXPORTER_VERSION") == "true": v = (999, 999, 999) generator = "VRM Add-on for Blender v" + ".".join(map(str, v)) asset_dict = json_dict.get("asset") if not isinstance(asset_dict, dict): asset_dict = {} json_dict["asset"] = asset_dict base_generator = asset_dict.get("generator") if isinstance(base_generator, str): generator += " with " + base_generator asset_dict["generator"] = generator if buffer0: buffer_dicts = json_dict.get("buffers") if not isinstance(buffer_dicts, list) or not buffer_dicts: buffer_dicts = [] json_dict["buffers"] = buffer_dicts if not buffer_dicts: buffer_dicts.append({}) buffer_dict = buffer_dicts[0] if not isinstance(buffer_dict, dict): buffer_dict = {} buffer_dicts[0] = buffer_dict buffer_dict["byteLength"] = len(buffer0) # https://github.com/KhronosGroup/glTF/blob/b6e0fcc6d8e9f83347aa8b2e3df085b81590a65c/specification/2.0/schema/glTF.schema.json gltf_root_non_empty_array_keys = [ "extensionsUsed", "extensionsRequired", "accessors", "animations", "buffers", "bufferViews", "cameras", "images", "materials", "meshes", "nodes", "samplers", "scenes", "skins", "textures", ] for key in gltf_root_non_empty_array_keys: if not json_dict.get(key): json_dict.pop(key, None) return pack_glb(json_dict, buffer0) def find_node_world_matrix( node_dicts: list[Json], target_node_index: int, parent_node_index: Optional[int], ) -> Optional[Matrix]: if parent_node_index is None: all_child_indices: list[int] = [] for node_dict in node_dicts: if not isinstance(node_dict, dict): continue child_node_indices = node_dict.get("children") if isinstance(child_node_indices, list): all_child_indices.extend( child_node_index for child_node_index in child_node_indices if isinstance(child_node_index, int) ) for node_index in range(len(node_dicts)): if node_index in all_child_indices: continue matrix = find_node_world_matrix(node_dicts, target_node_index, node_index) if matrix is not None: return matrix return Matrix() if not 0 <= parent_node_index < len(node_dicts): return None node_dict = node_dicts[parent_node_index] if not isinstance(node_dict, dict): return None parent_node_matrix = get_node_matrix(node_dict) if parent_node_index == target_node_index: return parent_node_matrix child_node_indices = node_dict.get("children") if not isinstance(child_node_indices, list): return None for child_node_index in child_node_indices: if not isinstance(child_node_index, int): continue child_node_matrix = find_node_world_matrix( node_dicts, target_node_index, child_node_index ) if child_node_matrix is not None: return parent_node_matrix @ child_node_matrix return None def get_node_matrix(node_dict: dict[str, Json]) -> Matrix: matrix = node_dict.get("matrix") if isinstance(matrix, list): if len(matrix) != 16: return Matrix() row0 = convert.float4_or_none((matrix[0], matrix[4], matrix[8], matrix[12])) row1 = convert.float4_or_none((matrix[1], matrix[5], matrix[9], matrix[13])) row2 = convert.float4_or_none((matrix[2], matrix[6], matrix[10], matrix[14])) row3 = convert.float4_or_none((matrix[3], matrix[7], matrix[11], matrix[15])) if not (row0 and row1 and row2 and row3): return Matrix() return Matrix((row0, row1, row2, row3)) location_matrix = Matrix() location = convert.float3_or_none(node_dict.get("translation")) if location: location_matrix = Matrix.Translation(location) rotation_matrix = Matrix() rotation = convert.float4_or_none(node_dict.get("rotation")) if rotation: x, y, z, w = rotation quaternion = Quaternion((w, x, y, z)) rotation_matrix = quaternion.to_matrix().to_4x4() scale_matrix = Matrix() scale = convert.float3_or_none(node_dict.get("scale")) if scale: scale_matrix = Matrix.Diagonal(scale).to_4x4() return location_matrix @ rotation_matrix @ scale_matrix def set_node_matrix(node_dict: dict[str, Json], matrix: Matrix) -> None: node_dict.pop("matrix", None) location, rotation, scale = matrix.decompose() node_dict["translation"] = list(location) node_dict["rotation"] = [ rotation.x, rotation.y, rotation.z, rotation.w, ] node_dict["scale"] = list(scale) def force_apply_modifiers_to_objects( context: Context, armature_object: Object, mesh_compatible_object_names: Sequence[str], ) -> None: selected_object_names: list[str] = [ obj.name for obj in context.selectable_objects if obj.select_get() ] for mesh_compatible_object_name in mesh_compatible_object_names: force_apply_modifiers_to_object( context, armature_object, mesh_compatible_object_name ) for obj in context.selectable_objects: obj.select_set(obj.name in selected_object_names) def force_apply_modifiers_to_object( context: Context, armature_object: Object, mesh_compatible_object_name: str, ) -> None: mesh_compatible_object = context.blend_data.objects.get(mesh_compatible_object_name) if not mesh_compatible_object: return if mesh_compatible_object.type == "MESH": mesh_object: Optional[Object] = mesh_compatible_object elif mesh_compatible_object.type in search.MESH_CONVERTIBLE_OBJECT_TYPES: with save_workspace(context, mesh_compatible_object): convert_result = bpy.ops.object.convert(target="MESH") if convert_result != {"FINISHED"}: logger.warning( "Failed to convert %s to MESH: %s", mesh_compatible_object.name, convert_result, ) return mesh_object = context.blend_data.objects.get(mesh_compatible_object_name) else: return if not mesh_object: return original_mesh_data = mesh_object.data if not isinstance(original_mesh_data, Mesh): return armature_modifier_name_to_show_render_and_show_viewport: dict[ str, tuple[bool, bool] ] = {} for modifier in list(mesh_object.modifiers): if ( not isinstance(modifier, ArmatureModifier) or modifier.object != armature_object ): continue armature_modifier_name_to_show_render_and_show_viewport[modifier.name] = ( modifier.show_render, modifier.show_viewport, ) modifier.show_render = False modifier.show_viewport = False try: mesh_data_name = original_mesh_data.name original_mesh_data.name = "Backup-Apply-Data-" + uuid4().hex mesh_data: Optional[ID] = force_apply_modifiers( context, mesh_object, preserve_shape_keys=True ) if not mesh_data: return original_mesh_data.user_remap(mesh_data) if original_mesh_data.users: logger.warning( 'Failed to remove "%s" with %d users while applying modifiers', original_mesh_data.name, original_mesh_data.users, ) else: context.blend_data.meshes.remove(original_mesh_data) mesh_data.name = mesh_data_name finally: for modifier in list(mesh_object.modifiers): if ( not isinstance(modifier, ArmatureModifier) or modifier.object != armature_object ): mesh_object.modifiers.remove(modifier) continue show_render_and_show_viewport = ( armature_modifier_name_to_show_render_and_show_viewport.get( modifier.name ) ) if show_render_and_show_viewport is None: continue modifier.show_render, modifier.show_viewport = show_render_and_show_viewport def is_identity_matrix(matrix: Matrix) -> bool: for row_index, row in enumerate(matrix): for column_index, value in enumerate(row): if row_index == column_index: if abs(value - 1) < float_info.epsilon: continue elif abs(value) < float_info.epsilon: continue return False return True