diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index 84b6a93..d14e235 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -60,19 +60,7 @@ class AnimationConverter: try: for bone_name, bone in generated_bones.items(): - child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) - child.child_anim = bone - # Copy animation modifier and its properties if it exists.. - # Cheating ? Very much yes. Do not try this at home, kids. - bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"] - bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"] - - # Now copy animation data. - anim_data = bone.animation_data_create() - action = bpy.data.actions.new("{}_action".format(bone.name)) - temporary_objects.append(action) - anim_data.action = action - self._exporter().report.warn(str([(i.data_path, i.array_index) for i in armature_action.fcurves])) + fcurves = [] for fcurve in armature_action.fcurves: match = self._bone_data_path_regex.match(fcurve.data_path) if not match: @@ -80,6 +68,18 @@ class AnimationConverter: name, data_path = match.groups() if name != bone_name: continue + fcurves.append((fcurve, data_path)) + + if not fcurves: + # No animation data for this bone. + continue + + # Copy animation data. + anim_data = bone.animation_data_create() + action = bpy.data.actions.new("{}_action".format(bone.name)) + temporary_objects.append(action) + anim_data.action = action + for fcurve, data_path in fcurves: new_curve = action.fcurves.new(data_path, fcurve.array_index) for point in fcurve.keyframe_points: # Thanks to bone_parent we can just copy the animation without a care in the world ! :P @@ -87,6 +87,13 @@ class AnimationConverter: for original_marker in armature_action.pose_markers: marker = action.pose_markers.new(original_marker.name) marker.frame = original_marker.frame + + # Copy animation modifier and its properties if it exists. + # Cheating ? Very much yes. Do not try this at home, kids. + bone.plasma_modifiers["animation"] = arm_bo.plasma_modifiers["animation"] + bone.plasma_modifiers["animation_loop"] = arm_bo.plasma_modifiers["animation_loop"] + child = exit_stack.enter_context(TemporaryCollectionItem(anim_group.children)) + child.child_anim = bone finally: if do_bake: bpy.data.actions.remove(armature_action) @@ -152,8 +159,9 @@ class AnimationConverter: # Do bake, but make sure we don't mess the user's data. old_action = bo.animation_data.action try: - frame_start = start if start is not None else bpy.context.scene.frame_start - frame_end = end if end is not None else bpy.context.scene.frame_end + keyframes = [keyframe.co[0] for curve in old_action.fcurves for keyframe in curve.keyframe_points] + frame_start = start if start is not None else min(keyframes) + frame_end = end if end is not None else max(keyframes) bpy.ops.nla.bake(frame_start=frame_start, frame_end=frame_end, step=bake_frame_step, only_selected=False, visual_keying=True, bake_types={"POSE", "OBJECT"}) baked_anim = bo.animation_data.action return baked_anim diff --git a/korman/exporter/armature.py b/korman/exporter/armature.py index 7063000..a2dcfb4 100644 --- a/korman/exporter/armature.py +++ b/korman/exporter/armature.py @@ -23,13 +23,15 @@ from . import utils class ArmatureConverter: def __init__(self, exporter): self._exporter = weakref.ref(exporter) + self._skinned_objects_modifiers = {} + self._bones_local_to_world = {} def convert_armature_to_empties(self, bo): # Creates Blender equivalents to each bone of the armature, adjusting a whole bunch of stuff along the way. # Yes, this is ugly, but required to get anims to export properly. I tried other ways to export armatures, - # but AFAICT sooner or later you have to implement similar hacks. Might as well create something that the + # but AFAICT sooner or later you have to implement similar hacks. Might as well generate something that the # animation exporter can already deal with, with no modification... - # Don't worry, we'll return a list of temporary objects to clean up after ourselves. + # Obviously the returned temporary_objects will be cleaned up afterwards. armature = bo.data pose = bo.pose if armature.pose_position == "POSE" else None generated_bones = {} # name: Blender empty. @@ -45,11 +47,44 @@ class ArmatureConverter: return temporary_objects + def get_bone_local_to_world(self, bo): + return self._bones_local_to_world[bo] + + def get_skin_modifiers(self, bo): + if self.is_skinned(bo): + return self._skinned_objects_modifiers[bo] + return [] + + def is_skinned(self, bo): + if bo.type != "MESH": + return False + if bo in self._skinned_objects_modifiers: + return True + + # We need to cache the armature modifiers, because mesh.py will likely fiddle with them later. + armatures = [] + for mod in bo.modifiers: + # Armature modifiers only result in exporting skinning if they are linked to an exported armature. + # If the armature is not exported, the deformation will simply get baked into the exported mesh. + if mod.type == "ARMATURE" and mod.object is not None and mod.object.plasma_object.enabled and mod.use_vertex_groups and mod.show_render: + armatures.append(mod) + if len(armatures): + self._skinned_objects_modifiers[bo] = armatures + return True + return False + def _export_bone(self, bo, bone, parent, matrix, pose, generated_bones, temporary_objects): bone_empty = bpy.data.objects.new(ArmatureConverter.get_bone_name(bo, bone), None) bpy.context.scene.objects.link(bone_empty) bone_empty.plasma_object.enabled = True + if pose is not None: + pose_bone = pose.bones[bone.name] + bone_empty.rotation_mode = pose_bone.rotation_mode + pose_matrix = pose_bone.matrix_basis + else: + pose_matrix = Matrix.Identity(4) + # Grmbl, animation is relative to rest pose in Blender, and relative to parent in Plasma... # Using matrix_parent_inverse or manually adjust keyframes will just mess up rotation keyframes, # so let's just insert an extra empty object to correct all that. This is why the CoordinateInterface caches computed matrices, after all... @@ -59,19 +94,14 @@ class ArmatureConverter: bone_parent.parent = parent bone_empty.parent = bone_parent bone_empty.matrix_local = Matrix.Identity(4) - - if pose is not None: - pose_bone = pose.bones[bone.name] - bone_empty.rotation_mode = pose_bone.rotation_mode - pose_matrix = pose_bone.matrix_basis - else: - pose_bone = None - pose_matrix = Matrix.Identity(4) + temporary_objects.append(bone_parent) + bone_parent.matrix_local = matrix * bone.matrix_local * pose_matrix + # The bone's local to world matrix may change when we copy animations over, which we don't want. + # Cache the matrix so we can use it when exporting meshes. + self._bones_local_to_world[bone_empty] = bo.matrix_world * bone.matrix_local temporary_objects.append(bone_empty) - temporary_objects.append(bone_parent) generated_bones[bone.name] = bone_empty - bone_parent.matrix_local = matrix * bone.matrix_local.to_4x4() * pose_matrix for child in bone.children: child_empty = self._export_bone(bo, child, bone_empty, bone.matrix_local.inverted(), pose, generated_bones, temporary_objects) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index f2cc4a8..98f6f73 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -257,28 +257,33 @@ class Exporter: def _export_actor(self, so, bo): """Exports a Coordinate Interface if we need one""" parent = bo.parent - parent_bone_name = bo.parent_bone + parent_bone_name = bo.parent_bone if parent is not None else None offset_matrix = None - if parent_bone_name: + if parent_bone_name and parent.plasma_object.enabled: # Object is parented to a bone, so use it instead. parent_bone = parent.data.bones[parent_bone_name] parent = bpy.context.scene.objects[ArmatureConverter.get_bone_name(parent, parent_bone)] # ...Actually, it's parented to the bone's /tip/. So we need to offset the child... - offset_matrix = Matrix.Translation((0, 0, -parent_bone.length)) + offset_matrix = Matrix.Translation(bo.matrix_local.row[1].xyz * parent_bone.length) if self.has_coordiface(bo): self._export_coordinate_interface(so, bo, offset_matrix) - # If this object has a parent, then we will need to go upstream and add ourselves to the - # parent's CoordinateInterface... Because life just has to be backwards. - if parent is not None: - if parent.plasma_object.enabled: - self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") - parent_ci = self._export_coordinate_interface(None, parent) - parent_ci.addChild(so.key) - else: - self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \ - The object may not appear in the correct location or animate properly.".format( - bo.name, parent.name)) + # If this object has a parent, then we will need to go upstream and add ourselves to the + # parent's CoordinateInterface... Because life just has to be backwards. + if parent is not None: + if parent.plasma_object.enabled: + if not self.armature.is_skinned(parent): + self.report.msg(f"Attaching to parent SceneObject '{parent.name}'") + parent_ci = self._export_coordinate_interface(None, parent) + parent_ci.addChild(so.key) + else: + self.report.warn("You have parented Plasma Object '{}' to '{}', which is deformed by an armature. This is not supported. \ + The object may not appear in the correct location or animate properly.".format( + bo.name, parent.name)) + else: + self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \ + The object may not appear in the correct location or animate properly.".format( + bo.name, parent.name)) def _export_coordinate_interface(self, so, bl, matrix: Matrix = None): """Ensures that the SceneObject has a CoordinateInterface""" @@ -425,6 +430,13 @@ class Exporter: inc_progress() def has_coordiface(self, bo): + if self.armature.is_skinned(bo): + # We forbid skinned objects from having a coordinate interface. See mesh.py for details. + for mod in bo.plasma_modifiers.modifiers: + if mod.enabled and mod.requires_actor: + raise RuntimeError("Object '{}' is skinned, which is incompatible with modifier '{}'.".format(bo, mod)) + return False + if bo.type in {"CAMERA", "EMPTY", "LAMP", "ARMATURE"}: return True if bo.parent is not None or bo.children: @@ -435,9 +447,8 @@ class Exporter: return True for mod in bo.plasma_modifiers.modifiers: - if mod.enabled: - if mod.requires_actor: - return True + if mod.enabled and mod.requires_actor: + return True return False def _post_process_scene_objects(self): @@ -490,6 +501,7 @@ class Exporter: def handle_temporary(temporary, parent): # Maybe this was an embedded context manager? if hasattr(temporary, "__enter__"): + log_msg(f"'{parent.name}': generated context manager '{temporary}' ") ctx_temporary = self.exit_stack.enter_context(temporary) if ctx_temporary is not None and ctx_temporary != temporary: return handle_temporary(ctx_temporary, parent) diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index 9812352..d30b42f 100644 --- a/korman/exporter/mesh.py +++ b/korman/exporter/mesh.py @@ -21,6 +21,7 @@ from math import fabs from typing import Iterable import weakref +from .armature import ArmatureConverter from ..exporter.logger import ExportProgressLogger from . import explosions from .. import helpers @@ -157,6 +158,8 @@ class _GeoData: self.blender2gs = [{} for i in range(numVtxs)] self.triangles = [] self.vertices = [] + self.max_deform_bones = 0 + self.total_weight_by_bones = {} class _MeshManager: @@ -166,6 +169,8 @@ class _MeshManager: self._report = report self._entered = False self._overrides = {} + self._objects_armatures = {} + self._geospans_armatures = {} @staticmethod def add_progress_presteps(report): @@ -200,15 +205,30 @@ class _MeshManager: # Remember, storing actual pointers to the Blender objects can cause bad things to # happen because Blender's memory management SUCKS! self._overrides[i.name] = { "mesh": i.data.name, "modifiers": [] } - i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False) # If the modifiers are left on the object, the lightmap bake can break under some - # situations. Therefore, we now cache the modifiers and clear them away... + # situations. Therefore, we now cache the modifiers and will clear them away... if i.plasma_object.enabled: cache_mods = self._overrides[i.name]["modifiers"] + for mod in i.modifiers: - cache_mods.append(self._build_prop_dict(mod)) + mod_prop_dict = self._build_prop_dict(mod) + cache_mods.append(mod_prop_dict) + + armatures = [] + for armature_mod in self._exporter().armature.get_skin_modifiers(i): + # We'll use armatures to export bones later. Disable it so it doesn't get baked into the mesh. + armatures.append(armature_mod.object) + # Note that this gets reverted when we reapply cached modifiers. + armature_mod.show_render = False + if armatures: + self._objects_armatures[i.name] = armatures + + i.data = i.to_mesh(scene, True, "RENDER", calc_tessface=False) + + if i.plasma_object.enabled: i.modifiers.clear() + self._report.progress_increment() return self @@ -365,6 +385,9 @@ class MeshConverter(_MeshManager): for dspan in loc.values(): log_msg("[DrawableSpans '{}']", dspan.key.name) + # We do one last pass to register bones. + self._register_bones_before_merge(dspan) + # This mega-function does a lot: # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers # 2. Calculates the Icicle bounds @@ -373,6 +396,87 @@ class MeshConverter(_MeshManager): dspan.composeGeometry(True, True) inc_progress() + def _register_bones_before_merge(self, dspan): + # We export all armatures used by this DSpan only once, unless an object uses multiple armatures - in which case, we treat the list of armatures as a single armature: + # [Armature 0: bone A, bone B, bone C...] + # [Armature 1: bone D, bone E, bone F...] + # [Armature 0, armature 1: bone A, bone B, bone C... bone D, bone E, bone F...] + # But really, the latter case should NEVER happen because users never use more than one armature modifier, cm'on. + # NOTE: we will export all bones, even those that are not used by any vertices in the DSpan. Would be too complex otherwise. + # NOTE: DSpan bone transforms are shared between all drawables in this dspan. This implies all skinned meshes must share the same + # coordinate system - and that's easier if these objects simply don't have a coordinate interface. + # Hence, we forbid skinned objects from having a coordint. + # The alternative is exporting each bone once per drawable with different l2w/w2l/l2b/b2l, but this is more complex + # and there is no good reason to do so - this just increases the risk of deformations going crazy due to a misaligned object. + + armature_list_to_base_matrix = {} + + def find_create_armature(armature_list): + nonlocal armature_list_to_base_matrix + existing_base_matrix = armature_list_to_base_matrix.get(armature_list) + if existing_base_matrix is not None: + # Armature already exported. Return the base bone ID. + return existing_base_matrix + + # Not already exported. Do so now. + # Will be used to offset the DI/icicle's baseMatrix. + base_matrix_id = dspan.numTransforms + armature_list_to_base_matrix[armature_list] = base_matrix_id + for armature in armature_list: + # Create the null bone. We have 1 per matrix + identity = hsMatrix44.Identity() + dspan.addTransform(identity, identity, identity, identity) + # Now create the transforms for all bones, and make sure they are referenced by a draw interface. + for bone in armature.data.bones: + find_create_bone(armature, bone) + + return base_matrix_id + + def find_create_bone(armature_bo, bone_bo): + bone_so_name = ArmatureConverter.get_bone_name(armature_bo, bone_bo) + bone_empty_bo = bpy.context.scene.objects[bone_so_name] + bone_empty_so = self._mgr.find_object(plSceneObject, bl=bone_empty_bo) + + # Add the bone's transform. + identity = hsMatrix44.Identity() + localToWorld = utils.matrix44(self._exporter().armature.get_bone_local_to_world(bone_empty_bo)) + transform_index = dspan.addTransform( \ + # local2world, world2local: always identity it seems. + identity, identity, \ + # local2bone, bone2local + localToWorld.inverse(), localToWorld) + + # Add a draw interface to the object itself (if not already done). + di = self._mgr.find_create_object(plDrawInterface, bl=bone_empty_bo, so=bone_empty_so) + bone_empty_so.draw = di.key + + # If the DI already has a reference to the DSpan, add the transform to the dspan's DIIndex. + # If not, create the DIIndex and the transform, and add it to the DI. + found = False + for key, id in di.drawables: + if dspan.key == key: + # Already exported because the user has an object with two armatures. + # Just readd the transform... + dii = dspan.DIIndices[id] + dii.indices = (*dii.indices, transform_index) + if not found: + dii = plDISpanIndex() + dii.flags = plDISpanIndex.kMatrixOnly + dii.indices = (transform_index,) + di_index = dspan.addDIIndex(dii) + di.addDrawable(dspan.key, di_index) + + for i in range(len(dspan.sourceSpans)): + geospan = dspan.sourceSpans[i] + # Let's get any armature data. + armature_info = self._geospans_armatures.get((dspan, i)) + if armature_info is None: + continue + armatures = armature_info[0] + # Export the armature (if not already done), and retrieve the BaseMatrix. + geospan.baseMatrix = find_create_armature(tuple(armatures)) + geospan.numMatrices = sum(len(arm.data.bones) for arm in armatures) + 1 + def _export_geometry(self, bo, mesh, materials, geospans, mat2span_LUT): self._report.msg(f"Converting geometry from '{mesh.name}'...") @@ -387,6 +491,26 @@ class MeshConverter(_MeshManager): color = self._find_vtx_color_layer(mesh.tessface_vertex_colors, autocolor=not lm.bake_lightmap, manual=True) alpha = self._find_vtx_alpha_layer(mesh.tessface_vertex_colors) + # And retrieve the vertex groups that are deformed by an armature. + armatures = self._objects_armatures.get(bo.name) + export_deform = armatures is not None + if export_deform: + # We will need to remap IDs of each bone per armature usage. This is annoying, especially since we support multiple armatures... + i = 1 + all_bone_names = {} + for armature in armatures: + for bone in armature.data.bones: + all_bone_names[bone.name] = i + i += 1 + # This will map the group ID (used by Blender vertices) to the bone index exported to Plasma. + # Theoretically supports multiple armatures, except if the two armatures have the same bone names (because that would be REALLY asking for a lot here). + # If the bone is not found, we'll just map to the null bone. + group_id_to_bone_id = [all_bone_names.get(group.name, 0) for group in bo.vertex_groups] + # We will also need to know which bones deform the most vertices per material, for max/pen bones. + for gd in geodata.values(): + gd.total_weight_by_bones = { j: 0.0 for j in range(i) } + warned_extra_bone_weights = False + # Convert Blender faces into things we can stuff into libHSPlasma for i, tessface in enumerate(mesh.tessfaces): data = geodata.get(tessface.material_index) @@ -484,6 +608,37 @@ class MeshConverter(_MeshManager): uvs.append(dPosDv) geoVertex.uvs = uvs + if export_deform: + # Get bone ID and weight from the vertex' "group" data. + # While we're at it, sort it by weight, and filter groups that + # have no bone assigned. Take only the first 3 bones. + weights = sorted([ \ + (group_id_to_bone_id[group.group], group.weight) \ + for group in source.groups \ + if group.weight > 0 \ + ], key=lambda t: t[1], reverse=True) + if len(weights) > 3 and not warned_extra_bone_weights: + warned_extra_bone_weights = True + self._report.warn(f"'{bo.name}': only three bones can deform a vertex at a time. Please use Weight Tools -> Limit Total at 3 to ensure deformation is consistent between Blender and Plasma.") + weights = weights[:3] + total_weight = sum((w[1] for w in weights)) + # NOTE: Blender will ALWAYS normalize bone weights when deforming ! + # This means if weights don't add up to 1, we CANNOT assign the remaining weight to the null bone. + # For instance, a vertex with a single bone of weight 0.25 will move as if it were weighted 1.0. + # However, a vertex with no bone at all will not move at all (null bone). + weights = [(id, weight / total_weight) for id, weight in weights] + # Count how many bones deform this vertex, so we know how many skin indices to enable. + num_bones = len(weights) + data.max_deform_bones = max(data.max_deform_bones, num_bones) + # Keep track of how much weight each bone is handling + for id, weight in weights: + data.total_weight_by_bones[id] += weight + # Pad to 3 weights to make it simpler. + weights += [(0, 0.0)] * (3 - len(weights)) + # And store all this into the vertex. + geoVertex.indices = weights[0][0] | (weights[1][0] << 8) | (weights[2][0] << 16) + geoVertex.weights = tuple((weight for id, weight in weights)) + idx = len(data.vertices) data.blender2gs[vertex][normcoluv] = idx data.vertices.append(geoVertex) @@ -535,6 +690,39 @@ class MeshConverter(_MeshManager): uvMap[numUVs - 1].normalize() vtx.uvs = uvMap + if export_deform: + # MaxBoneIdx and PenBoneIdx: these are the indices of the two highest-weighted bones on the mesh. + # Plasma will use those to compute a bounding box for the deformed object at run-time, + # in order to clip them when outside the view frustum. + # (The new BB is computed by extending the base BB with two versions of itself transformed by the max/pen bones). + # See plDrawableSpans::IUpdateMatrixPaletteBoundsHack. + # This is... about as reliable as you can expect: it kinda works, it's not great. This will have to do for now. + # (If you ask me, I'd rip the entire thing out and just not clip anything, framerate be damned.) + # Note that max/pen bones are determined by how many vertices they deform, which is a bit different (and more efficient) + # than whatever the Max plugin does in plMAXVertexAccumulator::StuffMyData. + sorted_ids_by_weight = sorted(((weight, id) for id, weight in data.total_weight_by_bones.items()), reverse = True) + # We should be guaranteed to have at least two bones - there are no armatures with no bones (...right?), + # and there is always the null bone if we really have nothing else. + geospan.maxBoneIdx = sorted_ids_by_weight[0][1] + geospan.penBoneIdx = sorted_ids_by_weight[1][1] + + # This is also a good time to specify how many bones per vertices we allow, for optimization purposes. + max_deform_bones = data.max_deform_bones + if max_deform_bones == 3: + geospan.format |= plGeometrySpan.kSkin3Weights | plGeometrySpan.kSkinIndices + elif max_deform_bones == 2: + geospan.format |= plGeometrySpan.kSkin2Weights | plGeometrySpan.kSkinIndices + else: # max_bones_per_vert == 1 + geospan.format |= plGeometrySpan.kSkin1Weight + if len(group_id_to_bone_id) > 1: + geospan.format |= plGeometrySpan.kSkinIndices + else: + # No skin indices required... BUT! We have assigned some weight to the null bone on top of the only bone, so we need to fix that. + for vtx in data.vertices: + weight = vtx.weights[0] + weight = 1 - weight + vtx.weights = (weight, 0.0, 0.0) + # If we're still here, let's add our data to the GeometrySpan geospan.indices = data.triangles geospan.vertices = data.vertices @@ -636,9 +824,13 @@ class MeshConverter(_MeshManager): dspan = self._find_create_dspan(bo, i.geospan, i.pass_index) self._report.msg("Exported hsGMaterial '{}' geometry into '{}'", i.geospan.material.name, dspan.key.name) + armatures = self._objects_armatures.get(bo.name) idx = dspan.addSourceSpan(i.geospan) diidx = _diindices.setdefault(dspan, []) diidx.append(idx) + if armatures is not None: + bone_id_to_name = {group.index: group.name for group in bo.vertex_groups} + self._geospans_armatures[(dspan, idx)] = (armatures, bone_id_to_name) # Step 3.1: Harvest Span indices and create the DIIndices drawables = [] diff --git a/korman/helpers.py b/korman/helpers.py index 8092415..fc34bb4 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -18,6 +18,7 @@ import bpy from contextlib import contextmanager import math from typing import * +from uuid import uuid4 @contextmanager def bmesh_from_object(bl): @@ -64,12 +65,14 @@ class GoodNeighbor: @contextmanager def TemporaryCollectionItem(collection): item = collection.add() + # Blender may recreate the `item` instance as the collection grows and shrink... + # Assign it a unique name so we know which item to delete later on. + name = item.name = str(uuid4()) try: yield item finally: - index = next((i for i, j in enumerate(collection) if j == item), None) - if index is not None: - collection.remove(index) + index = collection.find(name) + collection.remove(index) class TemporaryObject: def __init__(self, obj, remove_func): diff --git a/korman/render.py b/korman/render.py index 9677754..fa238f6 100644 --- a/korman/render.py +++ b/korman/render.py @@ -40,6 +40,7 @@ from bl_ui import properties_data_mesh properties_data_mesh.DATA_PT_normals.COMPAT_ENGINES.add("PLASMA_GAME") properties_data_mesh.DATA_PT_uv_texture.COMPAT_ENGINES.add("PLASMA_GAME") properties_data_mesh.DATA_PT_vertex_colors.COMPAT_ENGINES.add("PLASMA_GAME") +properties_data_mesh.DATA_PT_vertex_groups.COMPAT_ENGINES.add("PLASMA_GAME") del properties_data_mesh def _whitelist_all(mod):