From 07fd5ba12cdbbe3d4d04ccd83cfda3ec277f9094 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 10 Aug 2021 11:51:59 +0100 Subject: [PATCH] Improved animation workflow --- .../plugins/create/create_animation.py | 42 +++- .../blender/plugins/load/load_animation.py | 226 ++---------------- .../blender/plugins/load/load_layout_json.py | 25 +- .../hosts/blender/plugins/load/load_model.py | 2 - .../hosts/blender/plugins/load/load_rig.py | 27 ++- .../plugins/publish/collect_instances.py | 42 ++++ .../blender/plugins/publish/extract_blend.py | 2 +- .../publish/extract_blend_animation.py | 53 ++++ .../plugins/publish/extract_fbx_animation.py | 76 +++--- .../unreal/plugins/publish/extract_layout.py | 2 +- 10 files changed, 235 insertions(+), 262 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/extract_blend_animation.py diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 9aebf7e9b7..f7887b7e80 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -2,11 +2,13 @@ import bpy -from avalon import api, blender -import openpype.hosts.blender.api.plugin +from avalon import api +from avalon.blender import lib, ops +from avalon.blender.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin -class CreateAnimation(openpype.hosts.blender.api.plugin.Creator): +class CreateAnimation(plugin.Creator): """Animation output for character rigs""" name = "animationMain" @@ -15,16 +17,36 @@ class CreateAnimation(openpype.hosts.blender.api.plugin.Creator): icon = "male" def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + + def _process(self): + # Get Instance Containter or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + # name = self.name + # if not name: asset = self.data["asset"] subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + name = plugin.asset_name(asset, subset) + # asset_group = bpy.data.objects.new(name=name, object_data=None) + # asset_group.empty_display_type = 'SINGLE_ARROW' + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) self.data['task'] = api.Session.get('AVALON_TASK') - blender.lib.imprint(collection, self.data) + lib.imprint(asset_group, self.data) if (self.options or {}).get("useSelection"): - for obj in blender.lib.get_selection(): - collection.objects.link(obj) + selected = lib.get_selection() + for obj in selected: + asset_group.objects.link(obj) + elif (self.options or {}).get("asset_group"): + obj = (self.options or {}).get("asset_group") + asset_group.objects.link(obj) - return collection + return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_animation.py b/openpype/hosts/blender/plugins/load/load_animation.py index 4025fdfa74..4f589011dd 100644 --- a/openpype/hosts/blender/plugins/load/load_animation.py +++ b/openpype/hosts/blender/plugins/load/load_animation.py @@ -1,20 +1,19 @@ """Load an animation in Blender.""" import logging -from pathlib import Path -from pprint import pformat from typing import Dict, List, Optional -from avalon import api, blender import bpy -import openpype.hosts.blender.api.plugin + +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin logger = logging.getLogger("openpype").getChild( "blender").getChild("load_animation") -class BlendAnimationLoader(openpype.hosts.blender.api.plugin.AssetLoader): +class BlendAnimationLoader(plugin.AssetLoader): """Load animations from a .blend file. Warning: @@ -29,67 +28,6 @@ class BlendAnimationLoader(openpype.hosts.blender.api.plugin.AssetLoader): icon = "code-fork" color = "orange" - def _remove(self, objects, lib_container): - for obj in list(objects): - if obj.type == 'ARMATURE': - bpy.data.armatures.remove(obj.data) - elif obj.type == 'MESH': - bpy.data.meshes.remove(obj.data) - - bpy.data.collections.remove(bpy.data.collections[lib_container]) - - def _process(self, libpath, lib_container, container_name): - - relative = bpy.context.preferences.filepaths.use_relative_paths - with bpy.data.libraries.load( - libpath, link=True, relative=relative - ) as (_, data_to): - data_to.collections = [lib_container] - - scene = bpy.context.scene - - scene.collection.children.link(bpy.data.collections[lib_container]) - - anim_container = scene.collection.children[lib_container].make_local() - - meshes = [obj for obj in anim_container.objects if obj.type == 'MESH'] - armatures = [ - obj for obj in anim_container.objects if obj.type == 'ARMATURE'] - - # Should check if there is only an armature? - - objects_list = [] - - # Link meshes first, then armatures. - # The armature is unparented for all the non-local meshes, - # when it is made local. - for obj in meshes + armatures: - - obj = obj.make_local() - - obj.data.make_local() - - anim_data = obj.animation_data - - if anim_data is not None and anim_data.action is not None: - - anim_data.action.make_local() - - if not obj.get(blender.pipeline.AVALON_PROPERTY): - - obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) - - objects_list.append(obj) - - anim_container.pop(blender.pipeline.AVALON_PROPERTY) - - bpy.ops.object.select_all(action='DESELECT') - - return objects_list - def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None @@ -101,148 +39,32 @@ class BlendAnimationLoader(openpype.hosts.blender.api.plugin.AssetLoader): context: Full parenthood of representation to load options: Additional settings dictionary """ - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - container_name = openpype.hosts.blender.api.plugin.asset_name( - asset, subset, namespace - ) - container = bpy.data.collections.new(lib_container) - container.name = container_name - blender.pipeline.containerise_existing( - container, - name, - namespace, - context, - self.__class__.__name__, - ) + with bpy.data.libraries.load( + libpath, link=True, relative=False + ) as (data_from, data_to): + data_to.objects = data_from.objects + data_to.actions = data_from.actions - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + container = data_to.objects[0] - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + assert container, "No asset group found" - objects_list = self._process( - libpath, lib_container, container_name) + target_namespace = container.get(AVALON_PROPERTY).get('namespace') - # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list + action = data_to.actions[0].make_local().copy() - nodes = list(container.objects) - nodes.append(container) - self[:] = nodes - return nodes + for obj in bpy.data.objects: + if obj.get(AVALON_PROPERTY) and obj.get(AVALON_PROPERTY).get( + 'namespace') == target_namespace: + if obj.children[0]: + if not obj.children[0].animation_data: + obj.children[0].animation_data_create() + obj.children[0].animation_data.action = action + break - def update(self, container: Dict, representation: Dict): - """Update the loaded asset. + bpy.data.objects.remove(container) - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - - libpath = Path(api.get_representation_path(representation)) - extension = libpath.suffix.lower() - - logger.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert collection, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert not (collection.children), ( - "Nested collections are not supported." - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - - collection_libpath = collection_metadata["libpath"] - normalized_collection_libpath = ( - str(Path(bpy.path.abspath(collection_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - logger.debug( - "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_collection_libpath, - normalized_libpath, - ) - if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") - return - - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - self._remove(objects, lib_container) - - objects_list = self._process( - str(libpath), lib_container, collection.name) - - # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list - collection_metadata["libpath"] = str(libpath) - collection_metadata["representation"] = str(representation["_id"]) - - bpy.ops.object.select_all(action='DESELECT') - - def remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - if not collection: - return False - assert not (collection.children), ( - "Nested collections are not supported." - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - self._remove(objects, lib_container) - - bpy.data.collections.remove(collection) - - return True + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 8564b52816..dfa4501730 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -11,6 +11,7 @@ from avalon import api from avalon.blender.pipeline import AVALON_CONTAINERS from avalon.blender.pipeline import AVALON_CONTAINER_ID from avalon.blender.pipeline import AVALON_PROPERTY +from avalon.blender.pipeline import AVALON_INSTANCES from openpype.hosts.blender.api import plugin @@ -32,6 +33,14 @@ class JsonLayoutLoader(plugin.AssetLoader): for obj in objects: api.remove(obj.get(AVALON_PROPERTY)) + def _remove_animation_instances(self, asset_group): + instances = bpy.data.collections.get(AVALON_INSTANCES) + if instances: + for obj in list(asset_group.children): + anim_collection = instances.children.get(obj.name+"_animation") + if anim_collection: + bpy.data.collections.remove(anim_collection) + def _get_loader(self, loaders, family): name = "" if family == 'rig': @@ -48,7 +57,7 @@ class JsonLayoutLoader(plugin.AssetLoader): return None - def _process(self, libpath, asset_group, actions): + def _process(self, libpath, asset, asset_group, actions): bpy.ops.object.select_all(action='DESELECT') with open(libpath, "r") as fp: @@ -76,7 +85,9 @@ class JsonLayoutLoader(plugin.AssetLoader): options = { 'parent': asset_group, 'transform': element.get('transform'), - 'action': action + 'action': action, + 'create_animation': True if family == 'rig' else False, + 'animation_asset': asset } # This should return the loaded asset, but the load call will be @@ -121,7 +132,7 @@ class JsonLayoutLoader(plugin.AssetLoader): asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) - self._process(libpath, asset_group, None) + self._process(libpath, asset, asset_group, None) bpy.context.scene.collection.objects.link(asset_group) @@ -206,11 +217,13 @@ class JsonLayoutLoader(plugin.AssetLoader): if not rig: raise Exception("No armature in the rig asset group.") if rig.animation_data and rig.animation_data.action: - instance_name = obj_meta.get('instance_name') - actions[instance_name] = rig.animation_data.action + namespace = obj_meta.get('namespace') + actions[namespace] = rig.animation_data.action mat = asset_group.matrix_basis.copy() + self._remove_animation_instances(asset_group) + self._remove(asset_group) self._process(str(libpath), asset_group, actions) @@ -236,6 +249,8 @@ class JsonLayoutLoader(plugin.AssetLoader): if not asset_group: return False + self._remove_animation_instances(asset_group) + self._remove(asset_group) bpy.data.objects.remove(asset_group) diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index dd48be3db7..af5591c299 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -137,8 +137,6 @@ class BlendModelLoader(plugin.AssetLoader): rotation = transform.get('rotation') scale = transform.get('scale') - # Y position is inverted in sign because Unreal and Blender have the - # Y axis mirrored asset_group.location = ( location.get('x'), location.get('y'), diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index d12c398794..f3e2991a04 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -10,6 +10,7 @@ from avalon import api from avalon.blender.pipeline import AVALON_CONTAINERS from avalon.blender.pipeline import AVALON_CONTAINER_ID from avalon.blender.pipeline import AVALON_PROPERTY +from openpype import lib from openpype.hosts.blender.api import plugin @@ -164,18 +165,19 @@ class BlendRigLoader(plugin.AssetLoader): bpy.ops.object.select_all(action='DESELECT') + create_animation = False + if options is not None: parent = options.get('parent') transform = options.get('transform') action = options.get('action') + create_animation = options.get('create_animation') if parent and transform: location = transform.get('translation') rotation = transform.get('rotation') scale = transform.get('scale') - # Y position is inverted in sign because Unreal and Blender have the - # Y axis mirrored asset_group.location = ( location.get('x'), location.get('y'), @@ -201,6 +203,27 @@ class BlendRigLoader(plugin.AssetLoader): objects = self._process(libpath, asset_group, group_name, action) + if create_animation: + creator_plugin = lib.get_creator_by_name("CreateAnimation") + if not creator_plugin: + raise ValueError("Creator plugin \"CreateAnimation\" was " + "not found.") + + asset_group.select_set(True) + + animation_asset = options.get('animation_asset') + + api.create( + creator_plugin, + name=namespace+"_animation", + # name=f"{unique_number}_{subset}_animation", + asset=animation_asset, + options={"useSelection": False, "asset_group": asset_group}, + data={"dependencies": str(context["representation"]["_id"])} + ) + + bpy.ops.object.select_all(action='DESELECT') + bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 09a60d9725..0d683dace4 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -29,9 +29,23 @@ class CollectInstances(pyblish.api.ContextPlugin): if avalon_prop.get('id') == 'pyblish.avalon.instance': yield obj + @staticmethod + def get_collections() -> Generator: + """Return all 'model' collections. + + Check if the family is 'model' and if it doesn't have the + representation set. If the representation is set, it is a loaded model + and we don't want to publish it. + """ + for collection in bpy.data.collections: + avalon_prop = collection.get(AVALON_PROPERTY) or dict() + if avalon_prop.get('id') == 'pyblish.avalon.instance': + yield collection + def process(self, context): """Collect the models from the current Blender scene.""" asset_groups = self.get_asset_groups() + collections = self.get_collections() for group in asset_groups: avalon_prop = group[AVALON_PROPERTY] @@ -58,3 +72,31 @@ class CollectInstances(pyblish.api.ContextPlugin): self.log.debug(json.dumps(instance.data, indent=4)) for obj in instance: self.log.debug(obj) + + for collection in collections: + avalon_prop = collection[AVALON_PROPERTY] + asset = avalon_prop['asset'] + family = avalon_prop['family'] + subset = avalon_prop['subset'] + task = avalon_prop['task'] + name = f"{asset}_{subset}" + instance = context.create_instance( + name=name, + family=family, + families=[family], + subset=subset, + asset=asset, + task=task, + ) + members = list(collection.objects) + if family == "animation": + for obj in collection.objects: + if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): + for child in obj.children: + if child.type == 'ARMATURE': + members.append(child) + members.append(collection) + instance[:] = members + self.log.debug(json.dumps(instance.data, indent=4)) + for obj in instance: + self.log.debug(obj) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 60ef20e31c..6687c9fe76 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -11,7 +11,7 @@ class ExtractBlend(openpype.api.Extractor): label = "Extract Blend" hosts = ["blender"] - families = ["model", "camera", "rig", "action", "layout", "animation"] + families = ["model", "camera", "rig", "action", "layout"] optional = True def process(self, instance): diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py new file mode 100644 index 0000000000..239ca53f98 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -0,0 +1,53 @@ +import os + +import bpy + +import openpype.api + + +class ExtractBlendAnimation(openpype.api.Extractor): + """Extract a blend file.""" + + label = "Extract Blend" + hosts = ["blender"] + families = ["animation"] + optional = True + + def process(self, instance): + # Define extract output file path + + stagingdir = self.staging_dir(instance) + filename = f"{instance.name}.blend" + filepath = os.path.join(stagingdir, filename) + + # Perform extraction + self.log.info("Performing extraction..") + + data_blocks = set() + + for obj in instance: + if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY': + child = obj.children[0] + if child and child.type == 'ARMATURE': + if not obj.animation_data: + obj.animation_data_create() + obj.animation_data.action = child.animation_data.action + obj.animation_data_clear() + data_blocks.add(child.animation_data.action) + data_blocks.add(obj) + + bpy.data.libraries.write(filepath, data_blocks) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'blend', + 'ext': 'blend', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 8312114c7b..16443b760c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -1,14 +1,16 @@ import os import json -import openpype.api - import bpy import bpy_extras import bpy_extras.anim_utils +from openpype import api +from openpype.hosts.blender.api import plugin +from avalon.blender.pipeline import AVALON_PROPERTY -class ExtractAnimationFBX(openpype.api.Extractor): + +class ExtractAnimationFBX(api.Extractor): """Extract as animation.""" label = "Extract FBX" @@ -20,33 +22,26 @@ class ExtractAnimationFBX(openpype.api.Extractor): # Define extract output file path stagingdir = self.staging_dir(instance) - context = bpy.context - scene = context.scene - # Perform extraction self.log.info("Performing extraction..") - collections = [ - obj for obj in instance if type(obj) is bpy.types.Collection] + # The first collection object in the instance is taken, as there + # should be only one that contains the asset group. + collection = [ + obj for obj in instance if type(obj) is bpy.types.Collection][0] - assert len(collections) == 1, "There should be one and only one " \ - "collection collected for this asset" + # Again, the first object in the collection is taken , as there + # should be only the asset group in the collection. + asset_group = collection.objects[0] - old_scale = scene.unit_settings.scale_length + armature = [ + obj for obj in asset_group.children if obj.type == 'ARMATURE'][0] - # We set the scale of the scene for the export - scene.unit_settings.scale_length = 0.01 - - armatures = [ - obj for obj in collections[0].objects if obj.type == 'ARMATURE'] - - assert len(collections) == 1, "There should be one and only one " \ - "armature collected for this asset" - - armature = armatures[0] + asset_group_name = asset_group.name + asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") armature_name = armature.name - original_name = armature_name.split(':')[0] + original_name = armature_name.split(':')[1] armature.name = original_name object_action_pairs = [] @@ -89,27 +84,29 @@ class ExtractAnimationFBX(openpype.api.Extractor): for obj in bpy.data.objects: obj.select_set(False) + asset_group.select_set(True) armature.select_set(True) fbx_filename = f"{instance.name}_{armature.name}.fbx" filepath = os.path.join(stagingdir, fbx_filename) - override = bpy.context.copy() - override['selected_objects'] = [armature] + override = plugin.create_blender_context( + active=asset_group, selected=[asset_group, armature]) bpy.ops.export_scene.fbx( override, filepath=filepath, + use_active_collection=False, use_selection=True, bake_anim_use_nla_strips=False, bake_anim_use_all_actions=False, add_leaf_bones=False, armature_nodetype='ROOT', - object_types={'ARMATURE'} + object_types={'EMPTY', 'ARMATURE'} ) armature.name = armature_name + asset_group.name = asset_group_name + asset_group.select_set(False) armature.select_set(False) - scene.unit_settings.scale_length = old_scale - # We delete the baked action and set the original one back for i in range(0, len(object_action_pairs)): pair = object_action_pairs[i] @@ -125,18 +122,20 @@ class ExtractAnimationFBX(openpype.api.Extractor): json_filename = f"{instance.name}.json" json_path = os.path.join(stagingdir, json_filename) - json_dict = {} + json_dict = { + "instance_name": asset_group.get(AVALON_PROPERTY).get("namespace") + } - collection = instance.data.get("name") - container = None - for obj in bpy.data.collections[collection].objects: - if obj.type == "ARMATURE": - container_name = obj.get("avalon").get("container_name") - container = bpy.data.collections[container_name] - if container: - json_dict = { - "instance_name": container.get("avalon").get("instance_name") - } + # collection = instance.data.get("name") + # container = None + # for obj in bpy.data.collections[collection].objects: + # if obj.type == "ARMATURE": + # container_name = obj.get("avalon").get("container_name") + # container = bpy.data.collections[container_name] + # if container: + # json_dict = { + # "instance_name": container.get("avalon").get("instance_name") + # } with open(json_path, "w+") as file: json.dump(json_dict, fp=file, indent=2) @@ -159,6 +158,5 @@ class ExtractAnimationFBX(openpype.api.Extractor): instance.data["representations"].append(fbx_representation) instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '{}' to: {}".format( instance.name, fbx_representation)) diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 2d9f6eb3d1..a47187cf47 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -83,7 +83,7 @@ class ExtractLayout(openpype.api.Extractor): "z": transform.translation.z }, "rotation": { - "x": math.radians(transform.rotation.euler().x + 90.0), + "x": math.radians(transform.rotation.euler().x), "y": math.radians(transform.rotation.euler().y), "z": math.radians(180.0 - transform.rotation.euler().z) },