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

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
editor/mtoon1/handler.py Normal file
View File

@@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import bpy
from bpy.app.handlers import persistent
from ...common.logger import get_logger
from ...common.scene_watcher import trigger_scene_watcher
from . import migration
from .scene_watcher import MToon1AutoSetup, OutlineUpdater
logger = get_logger(__name__)
@persistent
def depsgraph_update_pre(_unused: object) -> None:
trigger_scene_watcher(MToon1AutoSetup)
if bpy.app.version < (3, 3):
return
trigger_scene_watcher(OutlineUpdater)
@persistent
def load_post(_unsed: object) -> None:
migration.state.material_blender_4_2_warning_shown = False

529
editor/mtoon1/migration.py Normal file
View File

@@ -0,0 +1,529 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import functools
from dataclasses import dataclass
from typing import Final, Optional
import bpy
from bpy.types import (
Context,
Image,
Material,
NodeReroute,
PropertyGroup,
ShaderNodeGroup,
ShaderNodeTexImage,
)
from ...common import convert, ops, shader, version
from ...common.gl import GL_LINEAR, GL_NEAREST
from ...common.logger import get_logger
from ...common.progress import create_progress
from .. import search
from ..extension import get_material_extension
from .property_group import (
GL_LINEAR_IMAGE_INTERPOLATIONS,
IMAGE_INTERPOLATION_CLOSEST,
IMAGE_INTERPOLATION_LINEAR,
Mtoon1MaterialPropertyGroup,
Mtoon1SamplerPropertyGroup,
Mtoon1TextureInfoPropertyGroup,
Mtoon1VrmcMaterialsMtoonPropertyGroup,
reset_shader_node_group,
)
TextureInfoBackup = Mtoon1TextureInfoPropertyGroup.TextureInfoBackup
logger = get_logger(__name__)
@dataclass
class State:
material_blender_4_2_warning_shown: bool = False
state: Final = State()
def show_material_blender_4_2_warning_delay(material_name_lines: str) -> None:
ops.vrm.show_material_blender_4_2_warning(
"INVOKE_DEFAULT",
material_name_lines=material_name_lines,
)
def migrate(context: Context, *, show_progress: bool = False) -> None:
blender_4_2_migrated_material_names: list[str] = []
with create_progress(context, show_progress=show_progress) as progress:
for material_index, material in enumerate(context.blend_data.materials):
if not material:
continue
migrate_material(context, material, blender_4_2_migrated_material_names)
progress.update(float(material_index) / len(context.blend_data.materials))
progress.update(1)
if (
blender_4_2_migrated_material_names
and tuple(context.blend_data.version) < (4, 2)
and bpy.app.version >= (4, 2)
):
logger.warning(
"Migrating Materials from blender version data=%s app=%s",
context.blend_data.version,
bpy.app.version,
)
if not state.material_blender_4_2_warning_shown:
state.material_blender_4_2_warning_shown = True
# In Blender 4.2.0, if you don't run it with a timer, the dialog will
# disappear automatically.
bpy.app.timers.register(
functools.partial(
show_material_blender_4_2_warning_delay,
"\n".join(blender_4_2_migrated_material_names),
),
first_interval=0.1,
)
def migrate_material(
context: Context,
material: Material,
blender_4_2_migrated_material_names: list[str],
) -> None:
_, legacy_legacy_shader_name = search.legacy_shader_node(material)
if legacy_legacy_shader_name in search.LEGACY_SHADER_NAMES:
# Since old shader node groups are not compatible with Blender 4.2 as they are,
# always warn when upgrading to Blender 4.2 or later.
blender_4_2_migrated_material_names.append(material.name)
return
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
return
node_tree = material.node_tree
if not node_tree:
return
mtoon1 = get_material_extension(material).mtoon1
if not mtoon1.get("enabled"):
return
vrmc_materials_mtoon = mtoon1.extensions.vrmc_materials_mtoon
if vrmc_materials_mtoon.get("is_outline_material"):
return
addon_version = convert.float3_or(mtoon1.get("addon_version"), (0, 0, 0))
if addon_version < (2, 16, 4):
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/2_10_0/io_scene_vrm/editor/mtoon1/property_group.py#L1658-L1683
surface_node_name = "Mtoon1Material.MaterialOutputSurfaceIn"
surface_node = node_tree.nodes.get(surface_node_name)
if not isinstance(surface_node, NodeReroute):
return
connected = False
surface_socket = surface_node.outputs[0]
for link in node_tree.links:
if (
link.from_socket == surface_socket
and link.to_socket
and link.to_socket.node
and link.to_socket.node.type == "OUTPUT_MATERIAL"
):
connected = True
break
if not connected:
return
else:
# https://github.com/saturday06/VRM-Addon-for-Blender/blob/2_16_4/io_scene_vrm/editor/mtoon1/property_group.py#L1913-L1929
group_node = node_tree.nodes.get("Mtoon1Material.Mtoon1Output")
if not isinstance(group_node, ShaderNodeGroup):
return
if not group_node.node_tree:
return
if group_node.node_tree.name != "VRM Add-on MToon 1.0 Output Revision 1":
return
if addon_version < (2, 20, 50):
migrate_sampler_filter_node(material)
alpha_mode: Optional[str] = None
alpha_cutoff: Optional[float] = None
if addon_version < (2, 20, 55):
blender_4_2_migrated_material_names.append(material.name)
alpha_cutoff = material.alpha_threshold
blend_method = material.blend_method
if blend_method in ["BLEND", "HASHED"]:
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_BLEND.identifier
elif blend_method == "CLIP":
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_MASK.identifier
else:
alpha_mode = Mtoon1MaterialPropertyGroup.ALPHA_MODE_OPAQUE.identifier
base_color_factor: Optional[tuple[float, float, float, float]] = None
base_color_texture_backup: Optional[TextureInfoBackup] = None
normal_texture_backup: Optional[TextureInfoBackup] = None
normal_texture_scale: Optional[float] = None
emissive_texture_backup: Optional[TextureInfoBackup] = None
emissive_factor: Optional[tuple[float, float, float]] = None
emissive_strength: Optional[float] = None
transparent_with_z_write: Optional[bool] = None
render_queue_offset_number: Optional[int] = None
shade_multiply_texture_backup: Optional[TextureInfoBackup] = None
shade_color_factor: Optional[tuple[float, float, float]] = None
shading_shift_texture_backup: Optional[TextureInfoBackup] = None
shading_shift_texture_scale: Optional[float] = None
shading_shift_factor: Optional[float] = None
shading_toony_factor: Optional[float] = None
gi_equalization_factor: Optional[float] = None
matcap_factor: Optional[tuple[float, float, float]] = None
matcap_texture_backup: Optional[TextureInfoBackup] = None
parametric_rim_color_factor: Optional[tuple[float, float, float]] = None
rim_multiply_texture_backup: Optional[TextureInfoBackup] = None
rim_lighting_mix_factor: Optional[float] = None
parametric_rim_fresnel_power_factor: Optional[float] = None
parametric_rim_lift_factor: Optional[float] = None
outline_width_mode: Optional[str] = None
outline_width_factor: Optional[float] = None
outline_width_multiply_texture_backup: Optional[TextureInfoBackup] = None
outline_color_factor: Optional[tuple[float, float, float]] = None
outline_lighting_mix_factor: Optional[float] = None
uv_animation_mask_texture_backup: Optional[TextureInfoBackup] = None
uv_animation_scroll_x_speed_factor: Optional[float] = None
uv_animation_scroll_y_speed_factor: Optional[float] = None
uv_animation_rotation_speed_factor: Optional[float] = None
if addon_version < (2, 20, 62):
pbr_metallic_roughness = mtoon1.pbr_metallic_roughness
base_color_factor = convert.float4_or_none(
pbr_metallic_roughness.get("base_color_factor")
)
base_color_texture_backup = backup_texture_info(
pbr_metallic_roughness.base_color_texture
)
normal_texture = mtoon1.normal_texture
normal_texture_backup = backup_texture_info(normal_texture)
normal_texture_scale = convert.float_or_none(normal_texture.get("scale"))
emissive_factor = convert.float3_or_none(mtoon1.get("emissive_factor"))
emissive_texture_backup = backup_texture_info(mtoon1.emissive_texture)
emissive_strength = convert.float_or_none(
mtoon1.extensions.khr_materials_emissive_strength.get("emissive_strength")
)
transparent_with_z_write_object = vrmc_materials_mtoon.get(
"transparent_with_z_write"
)
if isinstance(transparent_with_z_write_object, int):
transparent_with_z_write = bool(transparent_with_z_write_object)
render_queue_offset_number_object = vrmc_materials_mtoon.get(
"render_queue_offset_number"
)
if isinstance(render_queue_offset_number_object, int):
render_queue_offset_number = render_queue_offset_number_object
shade_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.shade_multiply_texture
)
shade_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("shade_color_factor")
)
shading_shift_texture = vrmc_materials_mtoon.shading_shift_texture
shading_shift_texture_backup = backup_texture_info(shading_shift_texture)
shading_shift_texture_scale = convert.float_or_none(
shading_shift_texture.get("scale")
)
shading_shift_factor = convert.float_or_none(
vrmc_materials_mtoon.get("shading_shift_factor")
)
shading_toony_factor = convert.float_or_none(
vrmc_materials_mtoon.get("shading_toony_factor")
)
matcap_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("matcap_factor")
)
matcap_texture_backup = backup_texture_info(vrmc_materials_mtoon.matcap_texture)
gi_equalization_factor = convert.float_or_none(
vrmc_materials_mtoon.get("gi_equalization_factor")
)
parametric_rim_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("parametric_rim_color_factor")
)
rim_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.rim_multiply_texture
)
rim_lighting_mix_factor = convert.float_or_none(
vrmc_materials_mtoon.get("rim_lighting_mix_factor")
)
parametric_rim_fresnel_power_factor = convert.float_or_none(
vrmc_materials_mtoon.get("parametric_rim_fresnel_power_factor")
)
parametric_rim_lift_factor = convert.float_or_none(
vrmc_materials_mtoon.get("parametric_rim_lift_factor")
)
outline_width_mode_number = vrmc_materials_mtoon.get("outline_width_mode")
if isinstance(outline_width_mode_number, int):
outline_width_mode_item = (
Mtoon1VrmcMaterialsMtoonPropertyGroup.outline_width_mode_enum
)
outline_width_mode = outline_width_mode_item.value_to_identifier(
outline_width_mode_number,
Mtoon1VrmcMaterialsMtoonPropertyGroup.OUTLINE_WIDTH_MODE_NONE.identifier,
)
outline_width_factor = convert.float_or_none(
vrmc_materials_mtoon.get("outline_width_factor")
)
outline_width_multiply_texture_backup = backup_texture_info(
vrmc_materials_mtoon.outline_width_multiply_texture
)
outline_color_factor = convert.float3_or_none(
vrmc_materials_mtoon.get("outline_color_factor")
)
outline_lighting_mix_factor = convert.float_or_none(
vrmc_materials_mtoon.get("outline_lighting_mix_factor")
)
uv_animation_mask_texture_backup = backup_texture_info(
vrmc_materials_mtoon.uv_animation_mask_texture
)
uv_animation_scroll_x_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_scroll_x_speed_factor")
)
uv_animation_scroll_y_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_scroll_y_speed_factor")
)
uv_animation_rotation_speed_factor = convert.float_or_none(
vrmc_materials_mtoon.get("uv_animation_rotation_speed_factor")
)
if (
addon_version < shader.LAST_MODIFIED_VERSION
# Blender 4.2 has a node specification change, so it will be forcibly reset.
or (bpy.app.version >= (4, 2) and tuple(context.blend_data.version) < (4, 2))
):
reset_shader_node_group(
context, material, reset_material_node_tree=True, reset_node_groups=False
)
# From this point on, you can write code that assumes the shader node is up to date.
vrmc_materials_mtoon = mtoon1.extensions.vrmc_materials_mtoon
if alpha_mode is not None:
mtoon1.alpha_mode = alpha_mode
if alpha_cutoff is not None:
mtoon1.alpha_cutoff = alpha_cutoff
if base_color_factor is not None:
mtoon1.pbr_metallic_roughness.base_color_factor = base_color_factor
if base_color_texture_backup is not None:
mtoon1.pbr_metallic_roughness.base_color_texture.restore(
base_color_texture_backup
)
if normal_texture_backup is not None:
mtoon1.normal_texture.restore(normal_texture_backup)
if normal_texture_scale is not None:
mtoon1.normal_texture.scale = normal_texture_scale
if emissive_texture_backup is not None:
mtoon1.emissive_texture.restore(emissive_texture_backup)
if emissive_factor is not None:
mtoon1.emissive_factor = emissive_factor
if emissive_strength is not None:
mtoon1.extensions.khr_materials_emissive_strength.emissive_strength = (
emissive_strength
)
if transparent_with_z_write is not None:
vrmc_materials_mtoon.transparent_with_z_write = transparent_with_z_write
if render_queue_offset_number is not None:
vrmc_materials_mtoon.render_queue_offset_number = render_queue_offset_number
if shade_multiply_texture_backup is not None:
vrmc_materials_mtoon.shade_multiply_texture.restore(
shade_multiply_texture_backup
)
if shade_color_factor is not None:
vrmc_materials_mtoon.shade_color_factor = shade_color_factor
if shading_shift_texture_backup is not None:
vrmc_materials_mtoon.shading_shift_texture.restore(shading_shift_texture_backup)
if shading_shift_factor is not None:
vrmc_materials_mtoon.shading_shift_factor = shading_shift_factor
if shading_shift_texture_scale is not None:
vrmc_materials_mtoon.shading_shift_texture.scale = shading_shift_texture_scale
if shading_toony_factor is not None:
vrmc_materials_mtoon.shading_toony_factor = shading_toony_factor
if gi_equalization_factor is not None:
vrmc_materials_mtoon.gi_equalization_factor = gi_equalization_factor
if matcap_factor is not None:
vrmc_materials_mtoon.matcap_factor = matcap_factor
if matcap_texture_backup is not None:
vrmc_materials_mtoon.matcap_texture.restore(matcap_texture_backup)
if parametric_rim_color_factor is not None:
vrmc_materials_mtoon.parametric_rim_color_factor = parametric_rim_color_factor
if rim_multiply_texture_backup is not None:
vrmc_materials_mtoon.rim_multiply_texture.restore(rim_multiply_texture_backup)
if rim_lighting_mix_factor is not None:
vrmc_materials_mtoon.rim_lighting_mix_factor = rim_lighting_mix_factor
if parametric_rim_fresnel_power_factor is not None:
vrmc_materials_mtoon.parametric_rim_fresnel_power_factor = (
parametric_rim_fresnel_power_factor
)
if parametric_rim_lift_factor is not None:
vrmc_materials_mtoon.parametric_rim_lift_factor = parametric_rim_lift_factor
if outline_width_mode is not None:
vrmc_materials_mtoon.outline_width_mode = outline_width_mode
if outline_width_factor is not None:
vrmc_materials_mtoon.outline_width_factor = outline_width_factor
if outline_width_multiply_texture_backup is not None:
vrmc_materials_mtoon.outline_width_multiply_texture.restore(
outline_width_multiply_texture_backup
)
if outline_color_factor is not None:
vrmc_materials_mtoon.outline_color_factor = outline_color_factor
if outline_lighting_mix_factor is not None:
vrmc_materials_mtoon.outline_lighting_mix_factor = outline_lighting_mix_factor
if uv_animation_mask_texture_backup is not None:
vrmc_materials_mtoon.uv_animation_mask_texture.restore(
uv_animation_mask_texture_backup
)
if uv_animation_scroll_x_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_scroll_x_speed_factor = (
uv_animation_scroll_x_speed_factor
)
if uv_animation_scroll_y_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_scroll_y_speed_factor = (
uv_animation_scroll_y_speed_factor
)
if uv_animation_rotation_speed_factor is not None:
vrmc_materials_mtoon.uv_animation_rotation_speed_factor = (
uv_animation_rotation_speed_factor
)
mtoon1.setup_drivers()
updated_addon_version = version.get_addon_version()
if tuple(mtoon1.addon_version) != updated_addon_version:
mtoon1.addon_version = updated_addon_version
def backup_texture_info(texture_info: object) -> Optional[TextureInfoBackup]:
if not isinstance(texture_info, PropertyGroup):
return None
source: Optional[Image] = None
wrap_s = Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.identifier
wrap_t = Mtoon1SamplerPropertyGroup.WRAP_DEFAULT.identifier
offset_x = 0.0
offset_y = 0.0
scale_x = 1.0
scale_y = 1.0
index = getattr(texture_info, "index", None)
if isinstance(index, PropertyGroup):
source_object = index.get("source")
if isinstance(source_object, Image):
source = source_object
sampler = getattr(index, "sampler", None)
if isinstance(sampler, PropertyGroup):
wrap_s_number = sampler.get("wrap_s")
if isinstance(wrap_s_number, int):
wrap_s = Mtoon1SamplerPropertyGroup.wrap_enum.value_to_identifier(
wrap_s_number,
wrap_s,
)
wrap_t_number = sampler.get("wrap_t")
if isinstance(wrap_t_number, int):
wrap_t = Mtoon1SamplerPropertyGroup.wrap_enum.value_to_identifier(
wrap_t_number,
wrap_t,
)
extensions = getattr(texture_info, "extensions", None)
if isinstance(extensions, PropertyGroup):
khr_texture_transform = getattr(extensions, "khr_texture_transform", None)
if isinstance(khr_texture_transform, PropertyGroup):
offset = convert.float2_or_none(khr_texture_transform.get("offset"))
if offset is not None:
offset_x, offset_y = offset
scale = convert.float2_or_none(khr_texture_transform.get("scale"))
if scale is not None:
scale_x, scale_y = scale
return TextureInfoBackup(
source=source,
mag_filter=None,
min_filter=None,
wrap_s=wrap_s,
wrap_t=wrap_t,
offset=(offset_x, offset_y),
scale=(scale_x, scale_y),
)
def migrate_sampler_filter_node(material: Material) -> None:
node_tree = material.node_tree
if not node_tree:
return
mtoon1 = get_material_extension(material).mtoon1
for node_name, attrs in [
(
"Mtoon1BaseColorTexture.Image",
("pbr_metallic_roughness", "base_color_texture"),
),
("Mtoon1EmissiveTexture.Image", ("emissive_texture",)),
("Mtoon1NormalTexture.Image", ("normal_texture",)),
(
"Mtoon1ShadeMultiplyTexture.Image",
("extensions", "vrmc_materials_mtoon", "shade_multiply_texture"),
),
(
"Mtoon1ShadingShiftTexture.Image",
("extensions", "vrmc_materials_mtoon", "shading_shift_texture"),
),
(
"Mtoon1OutlineWidthMultiplyTexture.Image",
(
"extensions",
"vrmc_materials_mtoon",
"outline_width_multiply_texture",
),
),
(
"Mtoon1UvAnimationMaskTexture.Image",
("extensions", "vrmc_materials_mtoon", "uv_animation_mask_texture"),
),
(
"Mtoon1MatcapTexture.Image",
("extensions", "vrmc_materials_mtoon", "matcap_texture"),
),
(
"Mtoon1RimMultiplyTexture.Image",
("extensions", "vrmc_materials_mtoon", "rim_multiply_texture"),
),
]:
sampler = functools.reduce(
lambda prop, attr: getattr(prop, attr, None),
(*attrs, "index", "sampler"),
mtoon1,
)
if not isinstance(sampler, PropertyGroup):
continue
mag_filter = sampler.get("mag_filter")
if (
not isinstance(mag_filter, int)
or mag_filter not in Mtoon1SamplerPropertyGroup.mag_filter_enum.values()
):
continue
node = node_tree.nodes.get(node_name)
if not isinstance(node, ShaderNodeTexImage):
continue
if (
mag_filter == GL_NEAREST
and node.interpolation != IMAGE_INTERPOLATION_CLOSEST
):
node.interpolation = IMAGE_INTERPOLATION_CLOSEST
if (
mag_filter == GL_LINEAR
and node.interpolation not in GL_LINEAR_IMAGE_INTERPOLATIONS
):
node.interpolation = IMAGE_INTERPOLATION_LINEAR

1158
editor/mtoon1/ops.py Normal file

File diff suppressed because it is too large Load Diff

447
editor/mtoon1/panel.py Normal file
View File

@@ -0,0 +1,447 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
from typing import Optional
import bpy
from bpy.app.translations import pgettext
from bpy.types import Context, Panel, PropertyGroup, UILayout
from ...common.logger import get_logger
from .. import search
from ..extension import get_material_extension
from ..ops import VRM_OT_open_url_in_web_browser, layout_operator
from .ops import (
VRM_OT_import_mtoon1_texture_image_file,
VRM_OT_reset_mtoon1_material_shader_node_tree,
)
from .property_group import (
Mtoon0TexturePropertyGroup,
Mtoon1MaterialPropertyGroup,
Mtoon1TextureInfoPropertyGroup,
)
logger = get_logger(__name__)
def draw_texture_info(
material_name: str,
ext: Mtoon1MaterialPropertyGroup,
parent_layout: UILayout,
base_property_group: PropertyGroup,
texture_info_attr_name: str,
color_factor_attr_name: Optional[str] = None,
*,
is_vrm0: bool,
) -> UILayout:
texture_info = getattr(base_property_group, texture_info_attr_name, None)
if not isinstance(texture_info, Mtoon1TextureInfoPropertyGroup):
raise TypeError
layout = parent_layout.split(factor=0.3)
toggle_layout = layout.row()
toggle_layout.alignment = "LEFT"
toggle_layout.prop(
texture_info,
"show_expanded",
emboss=False,
text=texture_info.index.panel_label,
translate=False,
icon="TRIA_DOWN" if texture_info.show_expanded else "TRIA_RIGHT",
)
input_layout = layout.row(align=True)
node_image = texture_info.index.get_connected_node_image()
if node_image == texture_info.index.source:
source_prop_name = "source"
placeholder = ""
else:
source_prop_name = "source_for_desynced_node_tree"
placeholder = node_image.name if node_image else ""
if bpy.app.version >= (4, 1):
input_layout.prop(
texture_info.index,
source_prop_name,
text="",
translate=False,
placeholder=placeholder,
)
else:
input_layout.prop(
texture_info.index,
source_prop_name,
text="",
translate=False,
)
import_image_file_op = layout_operator(
input_layout,
VRM_OT_import_mtoon1_texture_image_file,
text="",
translate=False,
icon="FILEBROWSER",
)
import_image_file_op.material_name = material_name
import_image_file_op.target_texture = type(texture_info.index).__name__
if color_factor_attr_name:
input_layout.separator(factor=0.5)
input_layout.prop(base_property_group, color_factor_attr_name, text="")
if not texture_info.show_expanded:
return input_layout
box = parent_layout.box().column()
if texture_info.index.source:
box.prop(texture_info.index.source.colorspace_settings, "name")
if (
texture_info.index.source.colorspace_settings.name
!= texture_info.index.colorspace
):
box.box().label(
text=pgettext(
'It is recommended to set "{colorspace}"'
+ ' to "{input_colorspace}" for "{texture_label}"'
).format(
texture_label=texture_info.index.label,
colorspace=pgettext(texture_info.index.colorspace),
input_colorspace=pgettext("Input Color Space"),
),
icon="ERROR",
)
box.prop(texture_info.index.sampler, "mag_filter")
box.prop(texture_info.index.sampler, "min_filter")
box.prop(texture_info.index.sampler, "wrap_s")
box.prop(texture_info.index.sampler, "wrap_t")
box.prop(texture_info.extensions.khr_texture_transform, "offset")
box.prop(texture_info.extensions.khr_texture_transform, "scale")
if is_vrm0:
if ext.extensions.vrmc_materials_mtoon.matcap_texture == texture_info:
box.box().label(
text="Offset and Scale are ignored in VRM 0.0", icon="ERROR"
)
elif ext.pbr_metallic_roughness.base_color_texture != texture_info:
box.box().label(
text="Offset and Scale in VRM 0.0 are"
+ " the values of the Lit Color Texture",
icon="ERROR",
)
return input_layout
def draw_mtoon0_texture(
material_name: str,
parent_layout: UILayout,
base_property_group: PropertyGroup,
texture_attr_name: str,
scalar_factor_attr_name: str,
) -> UILayout:
texture = getattr(base_property_group, texture_attr_name, None)
if not isinstance(texture, Mtoon0TexturePropertyGroup):
raise TypeError
layout = parent_layout.split(factor=0.3)
toggle_layout = layout.row()
toggle_layout.alignment = "LEFT"
toggle_layout.prop(
texture,
"show_expanded",
emboss=False,
text=texture.panel_label,
translate=False,
icon="TRIA_DOWN" if texture.show_expanded else "TRIA_RIGHT",
)
input_layout = layout.row(align=True)
input_layout.prop(texture, "source", text="")
import_image_file_op = layout_operator(
input_layout,
VRM_OT_import_mtoon1_texture_image_file,
text="",
translate=False,
icon="FILEBROWSER",
)
import_image_file_op.material_name = material_name
import_image_file_op.target_texture = type(texture).__name__
input_layout.separator(factor=0.5)
input_layout.prop(base_property_group, scalar_factor_attr_name, text="")
if not texture.show_expanded:
return input_layout
box = parent_layout.box().column()
if texture.source:
box.prop(texture.source.colorspace_settings, "name")
if texture.source.colorspace_settings.name != texture.colorspace:
box.box().label(
text=pgettext(
'It is recommended to set "{colorspace}"'
+ ' to "{input_colorspace}" for "{texture_label}"'
).format(
texture_label=texture.label,
colorspace=pgettext(texture.colorspace),
input_colorspace=pgettext("Input Color Space"),
),
icon="ERROR",
)
box.prop(texture.sampler, "mag_filter")
box.prop(texture.sampler, "min_filter")
box.prop(texture.sampler, "wrap_s")
box.prop(texture.sampler, "wrap_t")
return input_layout
def draw_mtoon1_material(context: Context, layout: UILayout) -> None:
material = context.material
if not material:
return
ext = get_material_extension(material)
layout = layout.column()
layout.prop(ext.mtoon1, "enabled")
if not ext.mtoon1.enabled:
return
is_vrm0 = search.current_armature_is_vrm0(context)
gltf = ext.mtoon1
mtoon = gltf.extensions.vrmc_materials_mtoon
# https://github.com/vrm-c/UniVRM/blob/v0.102.0/Assets/VRMShaders/VRM10/MToon10/Editor/MToonInspector.cs#L14
layout.label(text="Rendering", translate=False)
rendering_box = layout.box().column()
rendering_box.prop(gltf, "alpha_mode")
if gltf.alpha_mode == gltf.ALPHA_MODE_MASK.identifier:
rendering_box.prop(gltf, "alpha_cutoff", slider=True)
if gltf.alpha_mode == gltf.ALPHA_MODE_BLEND.identifier:
rendering_box.prop(mtoon, "transparent_with_z_write")
rendering_box.prop(gltf, "double_sided")
rendering_box.prop(mtoon, "render_queue_offset_number", slider=True)
layout.label(text="Lighting", translate=False)
lighting_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
gltf.pbr_metallic_roughness,
"base_color_texture",
"base_color_factor",
is_vrm0=is_vrm0,
)
draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
mtoon,
"shade_multiply_texture",
"shade_color_factor",
is_vrm0=is_vrm0,
)
normal_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
gltf,
"normal_texture",
is_vrm0=is_vrm0,
)
normal_texture_layout.separator(factor=0.5)
normal_texture_layout.prop(gltf.normal_texture, "scale")
lighting_box.prop(mtoon, "shading_toony_factor", slider=True)
lighting_box.prop(mtoon, "shading_shift_factor", slider=True)
shading_shift_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
lighting_box,
mtoon,
"shading_shift_texture",
is_vrm0=is_vrm0,
)
shading_shift_texture_layout.separator(factor=0.5)
shading_shift_texture_layout.prop(mtoon.shading_shift_texture, "scale")
# UniVRM (MIT License)
# https://github.com/vrm-c/UniVRM/blob/d2b4ad1964b754341873f2a1b093d58e1df1713f/Assets/VRMShaders/VRM10/MToon10/Editor/MToonInspector.cs#L120-L128
if (
not mtoon.shading_shift_texture.index.source
and mtoon.shading_toony_factor - mtoon.shading_shift_factor < 1.0 - 0.001
):
lighting_box.box().label(
text="The lit area includes non-lit area.", icon="ERROR"
)
layout.label(text="Global Illumination", translate=False)
gi_box = layout.box()
gi_box.prop(mtoon, "gi_equalization_factor", slider=True)
layout.label(text="Emission", translate=False)
emission_box = layout.box().column()
emissive_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
emission_box,
gltf,
"emissive_texture",
is_vrm0=is_vrm0,
).row(align=True)
emissive_texture_layout.scale_x = 0.71
emissive_texture_layout.separator(factor=0.5 / 0.71)
emissive_texture_layout.prop(gltf, "emissive_factor", text="")
emissive_texture_layout.prop(
gltf.extensions.khr_materials_emissive_strength, "emissive_strength"
)
layout.label(text="Rim Lighting", translate=False)
rim_lighting_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
rim_lighting_box,
mtoon,
"rim_multiply_texture",
is_vrm0=is_vrm0,
)
rim_lighting_box.prop(mtoon, "rim_lighting_mix_factor", slider=True)
draw_texture_info(
material.name,
ext.mtoon1,
rim_lighting_box,
mtoon,
"matcap_texture",
"matcap_factor",
is_vrm0=is_vrm0,
)
rim_lighting_box.row().prop(mtoon, "parametric_rim_color_factor")
rim_lighting_box.prop(mtoon, "parametric_rim_fresnel_power_factor", slider=True)
rim_lighting_box.prop(mtoon, "parametric_rim_lift_factor", slider=True)
layout.label(text="Outline", translate=False)
outline_box = layout.box().column()
outline_box.prop(mtoon, "outline_width_mode")
if (
bpy.app.version >= (3, 3)
and mtoon.outline_width_mode
== mtoon.OUTLINE_WIDTH_MODE_SCREEN_COORDINATES.identifier
):
outline_warning_message = pgettext(
'The "Screen Coordinates" display is not yet implemented.\n'
+ 'It is displayed in the same way as "World Coordinates".'
)
outline_warning_column = outline_box.box().column(align=True)
for index, outline_warning_line in enumerate(
outline_warning_message.splitlines()
):
outline_warning_column.label(
text=outline_warning_line,
translate=False,
icon="BLANK1" if index else "INFO",
)
if mtoon.outline_width_mode != mtoon.OUTLINE_WIDTH_MODE_NONE.identifier:
outline_width_multiply_texture_layout = draw_texture_info(
material.name,
ext.mtoon1,
outline_box,
mtoon,
"outline_width_multiply_texture",
is_vrm0=is_vrm0,
)
outline_width_multiply_texture_layout.separator(factor=0.5)
outline_width_multiply_texture_layout.prop(
mtoon, "outline_width_factor", slider=True, text=""
)
outline_box.row().prop(mtoon, "outline_color_factor")
outline_box.prop(mtoon, "outline_lighting_mix_factor", slider=True)
outline_box.prop(mtoon, "enable_outline_preview", text="Enable Preview")
layout.label(text="UV Animation", translate=False)
uv_animation_box = layout.box().column()
draw_texture_info(
material.name,
ext.mtoon1,
uv_animation_box,
mtoon,
"uv_animation_mask_texture",
is_vrm0=is_vrm0,
)
uv_animation_box.prop(mtoon, "uv_animation_scroll_x_speed_factor")
uv_animation_box.prop(mtoon, "uv_animation_scroll_y_speed_factor")
uv_animation_box.prop(mtoon, "uv_animation_rotation_speed_factor")
layout.prop(gltf, "show_expanded_mtoon0")
if gltf.show_expanded_mtoon0:
mtoon0_box = layout.box().column()
mtoon0_box.prop(gltf, "mtoon0_front_cull_mode")
draw_mtoon0_texture(
material.name,
mtoon0_box,
ext.mtoon1,
"mtoon0_receive_shadow_texture",
"mtoon0_receive_shadow_rate",
)
draw_mtoon0_texture(
material.name,
mtoon0_box,
ext.mtoon1,
"mtoon0_shading_grade_texture",
"mtoon0_shading_grade_rate",
)
mtoon0_box.prop(gltf, "mtoon0_light_color_attenuation", slider=True)
mtoon0_box.prop(gltf, "mtoon0_rim_lighting_mix", slider=True)
mtoon0_box.prop(gltf, "mtoon0_outline_scaled_max_distance", slider=True)
mtoon0_box.prop(gltf, "mtoon0_render_queue_and_clamp", slider=True)
reset_op = layout_operator(layout, VRM_OT_reset_mtoon1_material_shader_node_tree)
reset_op.material_name = material.name
def draw_material(context: Context, layout: UILayout) -> None:
material = context.material
if not material:
return
ext = get_material_extension(material)
if ext.mtoon1.is_outline_material:
layout.box().label(icon="INFO", text="This is an MToon Outline material")
return
draw_mtoon1_material(context, layout)
node, legacy_shader_name = search.legacy_shader_node(material)
if ext.mtoon1.enabled or (node and legacy_shader_name == "MToon_unversioned"):
layout.prop(ext.mtoon1, "export_shape_key_normals")
return
if node and legacy_shader_name in ["TRANSPARENT_ZWRITE", "GLTF"]:
return
help_column = layout.box().column(align=True)
help_message = pgettext(
"How to export this material to VRM.\n"
+ "Meet one of the following conditions.\n"
+ " - VRM MToon Material is enabled\n"
+ ' - Connect the "Surface" to a "Principled BSDF"\n'
+ ' - Connect the "Surface" to a "MToon_unversioned"\n'
+ ' - Connect the "Surface" to a "TRANSPARENT_ZWRITE"\n'
+ " - Others that are compatible with the glTF 2.0 add-on export\n"
)
for index, help_line in enumerate(help_message.splitlines()):
help_column.label(
text=help_line, translate=False, icon="HELP" if index == 0 else "NONE"
)
url = "https://docs.blender.org/manual/en/2.93/addons/import_export/scene_gltf2.html#exported-materials"
link_row = help_column.split(factor=0.8)
link_row.label(text=" " + url, translate=False)
web_op = layout_operator(link_row, VRM_OT_open_url_in_web_browser, icon="URL")
web_op.url = url
class VRM_PT_vrm_material_property(Panel):
bl_idname = "VRM_PT_vrm_material_property"
bl_label = "VRM Material"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "material"
@classmethod
def poll(cls, context: Context) -> bool:
return bool(context.material)
def draw(self, context: Context) -> None:
draw_material(context, self.layout)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import sys
from dataclasses import dataclass, field
from typing import Final, Optional
import bpy
from bpy.types import Context, Material, Mesh, ShaderNodeGroup, ShaderNodeOutputMaterial
from ...common import ops
from ...common.logger import get_logger
from ...common.scene_watcher import RunState, SceneWatcher
from ...common.shader import MTOON1_AUTO_SETUP_GROUP_NODE_TREE_CUSTOM_KEY
from ..extension import get_material_extension
from .ops import VRM_OT_refresh_mtoon1_outline, generate_mtoon1_outline_material_name
logger = get_logger(__name__)
HAS_AUTO_SMOOTH: Final = tuple(bpy.app.version) < (4, 1)
@dataclass
class ComparisonMaterial:
name: str
@dataclass
class ComparisonObject:
use_auto_smooth: Optional[bool] = None
comparison_materials: list[Optional[ComparisonMaterial]] = field(
default_factory=list[Optional[ComparisonMaterial]]
)
@dataclass
class OutlineUpdater(SceneWatcher):
comparison_objects: list[ComparisonObject] = field(
default_factory=list[ComparisonObject]
)
outline_material_key_to_material_name: dict[int, str] = field(
default_factory=dict[int, str]
)
object_index: int = 0
comparison_object_index: int = 0
material_slot_index: int = 0
@staticmethod
def get_outline_material_key(material: Material) -> int:
return material.as_pointer() ^ 0x5F5FF5F5
def reset_run_progress(self) -> None:
self.object_index = 0
self.comparison_object_index = 0
self.material_slot_index = 0
self.outline_material_key_to_material_name.clear()
def run(self, context: Context) -> RunState:
"""Detect changes in material assignments to objects and assign outlines."""
blend_data = context.blend_data
# If this value becomes zero, return PREEMPT and interrupt the process.
# If a change is detected, set a virtually infinite value so that the
# process proceeds to the end.
preempt_countdown = 15
changed = False
create_modifier = False
objects = blend_data.objects
if not objects:
return RunState.FINISH
# If the number of objects is less than the previous state and the index
# range is exceeded, start over from the beginning
objects_len = len(objects)
if self.object_index >= objects_len:
self.object_index = 0
# Scan objects and check for differences with the comparison object
next_object_index = self.object_index
for obj in objects[self.object_index : objects_len]:
self.object_index = next_object_index
next_object_index += 1
preempt_countdown -= 1
if preempt_countdown <= 0:
return RunState.PREEMPT
# Only mesh objects are subject to investigation.
# Skip if it is not a mesh object
obj_data = obj.data
if not isinstance(obj_data, Mesh):
continue
mesh = obj_data
# If the number of comparison objects is insufficient, add a new
# comparison object
while self.comparison_object_index >= len(self.comparison_objects):
self.comparison_objects.append(ComparisonObject())
# Get a comparison object
comparison_object = self.comparison_objects[self.comparison_object_index]
# Comparison of use_auto_smooth
if HAS_AUTO_SMOOTH and (
(use_auto_smooth := comparison_object.use_auto_smooth) is None
or (use_auto_smooth != mesh.use_auto_smooth)
):
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.use_auto_smooth = mesh.use_auto_smooth
# If the number of MaterialSlots is less than the previous state and
# the index range is exceeded, start over from the beginning
material_slots = obj.material_slots
material_slots_len = len(material_slots)
if self.material_slot_index >= material_slots_len:
self.material_slot_index = 0
# Match the number of MaterialSlots and the number of comparison Materials
while material_slots_len > len(comparison_object.comparison_materials):
comparison_object.comparison_materials.append(None)
while material_slots_len < len(comparison_object.comparison_materials):
comparison_object.comparison_materials.pop()
# Scan MaterialSlots and check for differences with the comparison Material
next_material_slot_index = self.material_slot_index
for material_slot in material_slots[
self.material_slot_index : material_slots_len
]:
material_slot_index = self.material_slot_index = (
next_material_slot_index
)
next_material_slot_index += 1
preempt_countdown -= 1
if preempt_countdown <= 0:
return RunState.PREEMPT
# If the number of comparison objects is insufficient, add a new
# comparison object
while material_slot_index >= len(
comparison_object.comparison_materials
):
comparison_object.comparison_materials.append(None)
comparison_material = comparison_object.comparison_materials[
material_slot_index
]
# Difference check
if not (
(material := material_slot.material)
and (mtoon1 := get_material_extension(material).mtoon1)
and mtoon1.get_enabled()
):
# MToon of the material in the material slot is disabled
if comparison_material is None:
# No updates
continue
# if the comparison object is enabled, a change is detected
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.comparison_materials[material_slot_index] = None
continue
# MToon of the material in the material slot is enabled
outline_material = mtoon1.outline_material
outline_material_name = generate_mtoon1_outline_material_name(material)
if (
comparison_material
and comparison_material.name == material.name
and (
outline_material is None
or outline_material.name == outline_material_name
)
):
continue
# MToon of the material in the material slot is enabled,
# but if the comparison object does not exist or the name
# does not match, a change is detected
changed, preempt_countdown = True, sys.maxsize
# Resolve change differences
comparison_object.comparison_materials[material_slot_index] = (
ComparisonMaterial(material.name)
)
# A material with MToon enabled has been newly assigned
# to the object so, if necessary, create a new outline
# modifier.
# Originally, True/False should be set for each object and
# material pair, but for now, I think it's okay to fix it
# for practical use.
create_modifier = True
if outline_material is None:
continue
# When a material is copied, a single outline material may be
# shared by multiple MToon materials. To resolve this, copy the
# outline material and reassign it.
outline_material_key = self.get_outline_material_key(outline_material)
original_material_name = self.outline_material_key_to_material_name.get(
outline_material_key
)
if original_material_name is None:
self.outline_material_key_to_material_name[outline_material_key] = (
material.name
)
elif original_material_name != material.name:
outline_material = outline_material.copy()
outline_material_key = self.get_outline_material_key(
outline_material
)
self.outline_material_key_to_material_name[outline_material_key] = (
material.name
)
mtoon1.outline_material = outline_material
# The outline material name follows the naming changes of
# the base MToon material.
if outline_material.name != outline_material_name:
outline_material.name = outline_material_name
# Since the scanning of MaterialSlots is complete,
# reset the next scanning index to 0.
self.material_slot_index = 0
# Before scanning the next object,
# advance the index of the next comparison object.
self.comparison_object_index += 1
# Since the number of elements in self.comparison_objects may be
# unnecessarily large, reduce it to a sufficient size
while len(self.comparison_objects) > self.comparison_object_index:
self.comparison_objects.pop()
if not changed:
return RunState.FINISH
VRM_OT_refresh_mtoon1_outline.refresh(context, create_modifier=create_modifier)
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(100):
blend_data.materials.new(f"Material#{i}")
for i in range(100):
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
for k in range(50):
material = blend_data.materials[(k * 3) % len(blend_data.materials)]
mesh.materials.append(material)
if k % 5 == 0:
get_material_extension(material).mtoon1.enabled = True
@dataclass
class MToon1AutoSetup(SceneWatcher):
last_material_index: int = 0
last_node_index: int = 0
def reset_run_progress(self) -> None:
self.last_material_index: int = 0
self.last_node_index: int = 0
def run(self, context: Context) -> RunState:
"""Monitor the appearance of MToon auto-setup node groups and set them up.
Since this function is called frequently, keep the processing lightweight and
be careful to minimize IO and GC Allocation.
"""
# If this value becomes 0 or less, interrupt the process
search_preempt_countdown = 100
materials = context.blend_data.materials
# Restore the material traversal start position from the last interrupted state.
end_material_index = len(materials)
start_material_index = self.last_material_index
if start_material_index >= end_material_index:
self.last_material_index = 0
self.last_node_index = 0
start_material_index = 0
# Traverse the materials and enable MToon if necessary.
next_material_index = start_material_index
for material in materials[start_material_index:end_material_index]:
self.last_material_index = next_material_index
next_material_index += 1
search_preempt_countdown -= 1
if search_preempt_countdown <= 0:
return RunState.PREEMPT
if bpy.app.version < (5, 0, 0) and not material.use_nodes:
continue
node_tree = material.node_tree
if node_tree is None:
continue
nodes = node_tree.nodes
# Restore the node traversal start position from the last interrupted state.
end_node_index = len(nodes)
start_node_index = self.last_node_index
if start_node_index >= end_node_index:
start_node_index = 0
# Traverse the nodes and convert the material to MToon if the MToon
# placeholder node is connected to ShaderNodeOutputMaterial.
next_node_index = start_node_index
for node in nodes[start_node_index:end_node_index]:
self.last_node_index = next_node_index
next_node_index += 1
search_preempt_countdown -= 1
if search_preempt_countdown <= 0:
return RunState.PREEMPT
if not isinstance(node, ShaderNodeGroup):
continue
group_node_tree = node.node_tree
if group_node_tree is None:
continue
if not group_node_tree.get(
MTOON1_AUTO_SETUP_GROUP_NODE_TREE_CUSTOM_KEY
):
continue
found = False
for output in node.outputs:
for link in output.links:
if isinstance(link.to_node, ShaderNodeOutputMaterial):
found = True
break
if found:
break
if not found:
continue
mtoon1 = get_material_extension(material).mtoon1
if mtoon1.enabled:
ops.vrm.reset_mtoon1_material_shader_node_group(
material_name=material.name
)
else:
mtoon1.enabled = True
break
self.last_node_index = 0
self.last_material_index = 0
return RunState.FINISH
def create_fast_path_performance_test_objects(self, context: Context) -> None:
blend_data = context.blend_data
for i in range(100):
blend_data.materials.new(f"Material#{i}")
for i in range(100):
mesh = blend_data.meshes.new(f"Mesh#{i}")
obj = blend_data.objects.new(f"Object#{i}", mesh)
context.scene.collection.objects.link(obj)
for k in range(50):
material = blend_data.materials[(k * 3) % len(blend_data.materials)]
mesh.materials.append(material)
if k % 5 == 0:
get_material_extension(material).mtoon1.enabled = True