Improved animation workflow

This commit is contained in:
Simone Barbieri 2021-08-10 11:51:59 +01:00
parent 78e10b9d1a
commit 07fd5ba12c
10 changed files with 235 additions and 262 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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'),

View file

@ -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] = {

View file

@ -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)

View file

@ -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):

View file

@ -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)

View file

@ -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))

View file

@ -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)
},