diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index 7e961dc..0d932bd 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -27,55 +27,331 @@ class AnimationConverter: self._exporter = weakref.ref(exporter) self._bl_fps = bpy.context.scene.render.fps - def convert_action2tm(self, action, default_xform): - """Converts a Blender Action to a plCompoundController.""" - fcurves = action.fcurves + def _convert_frame_time(self, frame_num): + return frame_num / self._bl_fps + + def convert_object_animations(self, bo, so): + if not self.is_animated(bo): + return + + def fetch_animation_data(id_data): + if id_data is not None: + if id_data.animation_data is not None: + action = id_data.animation_data.action + return action, getattr(action, "fcurves", None) + return None, None + + # TODO: At some point, we should consider supporting NLA stuff. + # But for now, this seems sufficient. + obj_action, obj_fcurves = fetch_animation_data(bo) + data_action, data_fcurves = fetch_animation_data(bo.data) + + # We're basically just going to throw all the FCurves at the controller converter (read: wall) + # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some + # form of separation, but Blender's NLA editor is way confusing and appears to not work with + # things that aren't the typical position, rotation, scale animations. + applicators = [] + applicators.append(self._convert_transform_animation(bo.name, obj_fcurves, bo.matrix_basis)) + if bo.plasma_modifiers.soundemit.enabled: + applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit)) + if isinstance(bo.data, bpy.types.Lamp): + lamp = bo.data + applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp)) + if isinstance(lamp, bpy.types.SpotLamp): + applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp)) + if isinstance(lamp, bpy.types.PointLamp): + applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp)) + + # Check to make sure we have some valid animation applicators before proceeding. + if not any(applicators): + return + + # There is a race condition in the client with animation loading. It expects for modifiers + # to be listed on the SceneObject in a specific order. D'OH! So, always use these funcs. + agmod, agmaster = self.get_anigraph_objects(bo, so) + atcanim = self._mgr.find_create_object(plATCAnim, so=so) + + # Add the animation data to the ATC + for i in applicators: + if i is not None: + atcanim.addApplicator(i) + agmod.channelName = bo.name + agmaster.addPrivateAnim(atcanim.key) + + # This was previously part of the Animation Modifier, however, there can be lots of animations + # Therefore we move it here. + def get_ranges(*args, **kwargs): + index = kwargs.get("index", 0) + for i in args: + if i is not None: + yield i.frame_range[index] + atcanim.name = "(Entire Animation)" + atcanim.start = self._convert_frame_time(min(get_ranges(obj_action, data_action, index=0))) + atcanim.end = self._convert_frame_time(max(get_ranges(obj_action, data_action, index=1))) + + # Marker points + if obj_action is not None: + for marker in obj_action.pose_markers: + atcanim.setMarker(marker.name, self._convert_frame_time(marker.frame)) + + # Fixme? Not sure if we really need to expose this... + atcanim.easeInMin = 1.0 + atcanim.easeInMax = 1.0 + atcanim.easeInLength = 1.0 + atcanim.easeOutMin = 1.0 + atcanim.easeOutMax = 1.0 + atcanim.easeOutLength = 1.0 + + def _convert_lamp_color_animation(self, name, fcurves, lamp): if not fcurves: return None - # NOTE: plCompoundController is from Myst 5 and was backported to MOUL. - # Worry not however... libHSPlasma will do the conversion for us. + energy_curve = next((i for i in fcurves if i.data_path == "energy" and i.keyframe_points), None) + color_curves = sorted((i for i in fcurves if i.data_path == "color" and i.keyframe_points), key=lambda x: x.array_index) + if energy_curve is None and color_curves is None: + return None + elif lamp.use_only_shadow: + self._exporter().report.warn("Cannot animate Lamp color because this lamp only casts shadows", indent=3) + return None + elif not lamp.use_specular and not lamp.use_diffuse: + self._exporter().report.warn("Cannot animate Lamp color because neither Diffuse nor Specular are enabled", indent=3) + return None + + # OK Specular is easy. We just toss out the color as a point3. + color_keyframes, color_bez = self._process_keyframes(color_curves, convert=lambda x: x * -1.0 if lamp.use_negative else None) + if color_keyframes and lamp.use_specular: + channel = plPointControllerChannel() + channel.controller = self._make_point3_controller(color_curves, color_keyframes, color_bez, lamp.color) + applicator = plLightSpecularApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + + # Hey, look, it's a third way to process FCurves. YAY! + def convert_diffuse_animation(color, energy): + if lamp.use_negative: + return { key: (0.0 - value) * energy[0] for key, value in color.items() } + else: + return { key: value * energy[0] for key, value in color.items() } + diffuse_defaults = { "color": lamp.color, "energy": lamp.energy } + diffuse_fcurves = color_curves + [energy_curve,] + diffuse_keyframes = self._process_fcurves(diffuse_fcurves, convert_diffuse_animation, diffuse_defaults) + if not diffuse_keyframes: + return None + + # Whew. + channel = plPointControllerChannel() + channel.controller = self._make_point3_controller([], diffuse_keyframes, False, []) + applicator = plLightDiffuseApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + + def _convert_omni_lamp_animation(self, name, fcurves, lamp): + energy_fcurve = next((i for i in fcurves if i.data_path == "energy"), None) + distance_fcurve = next((i for i in fcurves if i.data_path == "distance"), None) + if energy_fcurve is None and distance_fcurve is None: + return None + light_converter = self._exporter().light + intensity, atten_end = light_converter.convert_attenuation(lamp) + + # All types allow animating cutoff + if distance_fcurve is not None: + channel = plScalarControllerChannel() + channel.controller = self.make_scalar_leaf_controller(distance_fcurve, + lambda x: x * 2 if lamp.use_sphere else x) + applicator = plOmniCutoffApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + + falloff = lamp.falloff_type + if falloff == "CONSTANT": + if energy_fcurve is not None: + self._exporter().report.warn("Constant attenuation cannot be animated in Plasma", ident=3) + elif falloff == "INVERSE_LINEAR": + def convert_linear_atten(distance, energy): + intens = abs(energy[0]) + atten_end = distance[0] * 2 if lamp.use_sphere else distance[0] + return light_converter.convert_attenuation_linear(intens, atten_end) + + keyframes = self._process_fcurves([distance_fcurve, energy_fcurve], convert_linear_atten, + {"distance": lamp.distance, "energy": lamp.energy}) + if keyframes: + channel = plScalarControllerChannel() + channel.controller = self._make_scalar_leaf_controller(keyframes, False) + applicator = plOmniApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + elif falloff == "INVERSE_SQUARE": + def convert_quadratic_atten(distance, energy): + intens = abs(energy[0]) + atten_end = distance[0] * 2 if lamp.use_sphere else distance[0] + return light_converter.convert_attenuation_quadratic(intens, atten_end) + + keyframes = self._process_fcurves([distance_fcurve, energy_fcurve], convert_quadratic_atten, + {"distance": lamp.distance, "energy": lamp.energy}) + if keyframes: + channel = plScalarControllerChannel() + channel.controller = self._make_scalar_leaf_controller(keyframes, False) + applicator = plOmniSqApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + else: + self._exporter().report.warn("Lamp Falloff '{}' animations are not supported".format(falloff), ident=3) + + def _convert_sound_volume_animation(self, name, fcurves, soundemit): + if not fcurves: + return None + + def convert_volume(value): + if value == 0.0: + return 0.0 + else: + return math.log10(value) * 20.0 + + for sound in soundemit.sounds: + path = "{}.volume".format(sound.path_from_id()) + fcurve = next((i for i in fcurves if i.data_path == path and i.keyframe_points), None) + if fcurve is None: + continue + + for i in soundemit.get_sound_indices(sound=sound): + applicator = plSoundVolumeApplicator() + applicator.channelName = name + applicator.index = i + + # libHSPlasma assumes a channel is not shared among applicators... + # so yes, we must convert the same animation data again and again. + channel = plScalarControllerChannel() + channel.controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume) + applicator.channel = channel + yield applicator + + def _convert_spot_lamp_animation(self, name, fcurves, lamp): + blend_fcurve = next((i for i in fcurves if i.data_path == "spot_blend"), None) + size_fcurve = next((i for i in fcurves if i.data_path == "spot_size"), None) + if blend_fcurve is None and size_fcurve is None: + return None + + # Spot Outer is just the size keyframes... + if size_fcurve is not None: + channel = plScalarControllerChannel() + channel.controller = self.make_scalar_leaf_controller(size_fcurve, lambda x: math.degrees(x)) + applicator = plSpotOuterApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + + # Spot inner must be calculated... + def convert_spot_inner(spot_blend, spot_size): + blend = min(0.001, spot_blend[0]) + size = spot_size[0] + value = size - (blend * size) + return math.degrees(value) + defaults = { "spot_blend": lamp.spot_blend, "spot_size": lamp.spot_size } + keyframes = self._process_fcurves([blend_fcurve, size_fcurve], convert_spot_inner, defaults) + + if keyframes: + channel = plScalarControllerChannel() + channel.controller = self._make_scalar_leaf_controller(keyframes, False) + applicator = plSpotInnerApplicator() + applicator.channelName = name + applicator.channel = channel + yield applicator + + def _convert_transform_animation(self, name, fcurves, xform): + if not fcurves: + return None + + pos = self.make_pos_controller(fcurves, xform) + rot = self.make_rot_controller(fcurves, xform) + scale = self.make_scale_controller(fcurves, xform) + if pos is None and rot is None and scale is None: + return None + tm = plCompoundController() - tm.X = self.make_pos_controller(fcurves, default_xform) - tm.Y = self.make_rot_controller(fcurves, default_xform) - tm.Z = self.make_scale_controller(fcurves, default_xform) - return tm - - def make_matrix44_controller(self, pos_fcurves, scale_fcurves, default_pos, default_scale): - pos_keyframes, pos_bez = self._process_keyframes(pos_fcurves) - scale_keyframes, scale_bez = self._process_keyframes(scale_fcurves) - if not pos_keyframes and not scale_keyframes: + tm.X = pos + tm.Y = rot + tm.Z = scale + + applicator = plMatrixChannelApplicator() + applicator.enabled = True + applicator.channelName = name + channel = plMatrixControllerChannel() + channel.controller = tm + applicator.channel = channel + + # Decompose the matrix into the 90s-era 3ds max affine parts sillyness + # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... + affine = hsAffineParts() + affine.T = hsVector3(*xform.to_translation()) + affine.K = hsVector3(*xform.to_scale()) + affine.F = -1.0 if xform.determinant() < 0.0 else 1.0 + rot = xform.to_quaternion() + affine.Q = utils.quaternion(rot) + rot.normalize() + affine.U = utils.quaternion(rot) + channel.affine = affine + + return applicator + + def get_anigraph_keys(self, bo=None, so=None): + mod = self._mgr.find_create_key(plAGModifier, so=so, bl=bo) + master = self._mgr.find_create_key(plAGMasterMod, so=so, bl=bo) + return mod, master + + def get_anigraph_objects(self, bo=None, so=None): + mod = self._mgr.find_create_object(plAGModifier, so=so, bl=bo) + master = self._mgr.find_create_object(plAGMasterMod, so=so, bl=bo) + return mod, master + + def has_transform_animation(self, bo): + if bo.animation_data is not None: + if bo.animation_data.action is not None: + data_paths = frozenset((i.data_path for i in bo.animation_data.action.fcurves)) + return {"location", "rotation_euler", "scale"} & data_paths + return False + + def is_animated(self, bo): + if bo.animation_data is not None: + if bo.animation_data.action is not None: + return True + data = getattr(bo, "data", None) + if data is not None: + if data.animation_data is not None: + if data.animation_data.action is not None: + return True + return False + + def make_matrix44_controller(self, fcurves, pos_path, scale_path, pos_default, scale_default): + def convert_matrix_keyframe(**kwargs): + pos = kwargs.get(pos_path) + scale = kwargs.get(scale_path) + + matrix = hsMatrix44() + # Note: scale and pos are dicts, so we can't unpack + matrix.setTranslate(hsVector3(pos[0], pos[1], pos[2])) + matrix.setScale(hsVector3(scale[0], scale[1], scale[2])) + return matrix + + fcurves = [i for i in fcurves if i.data_path == pos_path or i.data_path == scale_path] + if not fcurves: return None - # Matrix keyframes cannot do bezier schtuff - if pos_bez or scale_bez: - self._exporter().report.warn("This animation cannot use bezier keyframes--forcing linear", indent=3) - - # Let's pair up the pos and scale schtuff based on frame numbers. I realize that we're creating - # a lot of temporary objects, but until I see profiling results that this is terrible, I prefer - # to have code that makes sense. - keyframes = [] - for pos, scale in itertools.zip_longest(pos_keyframes, scale_keyframes, fillvalue=None): - if pos is None: - keyframes.append((None, scale)) - elif scale is None: - keyframes.append((pos, scale)) - elif pos.frame_num == scale.frame_num: - keyframes.append((pos, scale)) - elif pos.frame_num < scale.frame_num: - keyframes.append((pos, None)) - keyframes.append((None, scale)) - elif pos.frame_num > scale.frame_num: - keyframes.append((None, scale)) - keyframes.append((pos, None)) + default_values = { pos_path: pos_default, scale_path: scale_default } + keyframes = self._process_fcurves(fcurves, convert_matrix_keyframe, default_values) + if not keyframes: + return None # Now we make the controller - ctrl = self._make_matrix44_controller(pos_fcurves, scale_fcurves, keyframes, default_pos, default_scale) - return ctrl + return self._make_matrix44_controller(keyframes) - def make_pos_controller(self, fcurves, default_xform): + def make_pos_controller(self, fcurves, default_xform, convert=None): pos_curves = [i for i in fcurves if i.data_path == "location" and i.keyframe_points] - keyframes, bez_chans = self._process_keyframes(pos_curves) + keyframes, bez_chans = self._process_keyframes(pos_curves, convert) if not keyframes: return None @@ -84,10 +360,10 @@ class AnimationConverter: ctrl = self._make_point3_controller(pos_curves, keyframes, bez_chans, default_xform.to_translation()) return ctrl - def make_rot_controller(self, fcurves, default_xform): + def make_rot_controller(self, fcurves, default_xform, convert=None): # TODO: support rotation_quaternion rot_curves = [i for i in fcurves if i.data_path == "rotation_euler" and i.keyframe_points] - keyframes, bez_chans = self._process_keyframes(rot_curves) + keyframes, bez_chans = self._process_keyframes(rot_curves, convert=None) if not keyframes: return None @@ -99,9 +375,9 @@ class AnimationConverter: ctrl = self._make_quat_controller(rot_curves, keyframes, default_xform.to_euler()) return ctrl - def make_scale_controller(self, fcurves, default_xform): + def make_scale_controller(self, fcurves, default_xform, convert=None): scale_curves = [i for i in fcurves if i.data_path == "scale" and i.keyframe_points] - keyframes, bez_chans = self._process_keyframes(scale_curves) + keyframes, bez_chans = self._process_keyframes(scale_curves, convert) if not keyframes: return None @@ -109,49 +385,25 @@ class AnimationConverter: ctrl = self._make_scale_value_controller(scale_curves, keyframes, bez_chans, default_xform) return ctrl - def make_scalar_leaf_controller(self, fcurve): - keyframes, bezier = self._process_fcurve(fcurve) + def make_scalar_leaf_controller(self, fcurve, convert=None): + keyframes, bezier = self._process_fcurve(fcurve, convert) if not keyframes: return None ctrl = self._make_scalar_leaf_controller(keyframes, bezier) return ctrl - def _make_matrix44_controller(self, pos_fcurves, scale_fcurves, keyframes, default_pos, default_scale): + def _make_matrix44_controller(self, keyframes): ctrl = plLeafController() keyframe_type = hsKeyFrame.kMatrix44KeyFrame exported_frames = [] - pcurves = { i.array_index: i for i in pos_fcurves } - scurves = { i.array_index: i for i in scale_fcurves } - def eval_fcurve(fcurves, keyframe, i, default_xform): - try: - return fcurves[i].evaluate(keyframe.frame_num_blender) - except KeyError: - return default_xform[i] - - for pos_key, scale_key in keyframes: - valid_key = pos_key if pos_key is not None else scale_key + for keyframe in keyframes: exported = hsMatrix44Key() - exported.frame = valid_key.frame_num - exported.frameTime = valid_key.frame_time + exported.frame = keyframe.frame_num + exported.frameTime = keyframe.frame_time exported.type = keyframe_type - - if pos_key is not None: - pos_value = [pos_key.values[i] if i in pos_key.values else eval_fcurve(pcurves, pos_key, i, default_pos) for i in range(3)] - else: - pos_value = [eval_fcurve(pcurves, valid_key, i, default_pos) for i in range(3)] - if scale_key is not None: - scale_value = [scale_key.values[i] if i in scale_key.values else eval_fcurve(scurves, scale_key, i, default_scale) for i in range(3)] - else: - scale_value = [eval_fcurve(scurves, valid_key, i, default_scale) for i in range(3)] - pos_value = hsVector3(*pos_value) - scale_value = hsVector3(*scale_value) - - value = hsMatrix44() - value.setTranslate(pos_value) - value.setScale(scale_value) - exported.value = value + exported.value = keyframe.value exported_frames.append(exported) ctrl.keys = (exported_frames, keyframe_type) return ctrl @@ -315,7 +567,7 @@ class AnimationConverter: ctrl.keys = (exported_frames, keyframe_type) return ctrl - def _process_fcurve(self, fcurve): + def _process_fcurve(self, fcurve, convert=None): """Like _process_keyframes, but for one fcurve""" keyframe_data = type("KeyFrameData", (), {}) fps = self._bl_fps @@ -339,12 +591,121 @@ class AnimationConverter: else: keyframe.in_tan = 0.0 keyframe.out_tan = 0.0 - keyframe.value = value + keyframe.value = value if convert is None else convert(value) keyframes[frame_num] = keyframe final_keyframes = [keyframes[i] for i in sorted(keyframes)] return (final_keyframes, bezier) - def _process_keyframes(self, fcurves): + def _process_fcurves(self, fcurves, convert, defaults=None): + """Processes FCurves of different data sets and converts them into a single list of keyframes. + This should be used when multiple Blender fields map to a single Plasma option.""" + class KeyFrameData: + def __init__(self): + self.values = {} + fps = self._bl_fps + pi = math.pi + + # It is assumed therefore that any multichannel FCurves will have all channels represented. + # This seems fairly safe with my experiments with Lamp colors... + grouped_fcurves = {} + for fcurve in fcurves: + if fcurve is None: + continue + fcurve.update() + if fcurve.data_path in grouped_fcurves: + grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve + else: + grouped_fcurves[fcurve.data_path] = { fcurve.array_index: fcurve } + + # Default values for channels that are not animated + for key, value in defaults.items(): + if key not in grouped_fcurves: + if hasattr(value, "__len__"): + grouped_fcurves[key] = value + else: + grouped_fcurves[key] = [value,] + + # Assemble a dict { PlasmaFrameNum: { FCurveDataPath: KeyFrame } } + keyframe_points = {} + for fcurve in fcurves: + if fcurve is None: + continue + for keyframe in fcurve.keyframe_points: + frame_num_blender, value = keyframe.co + frame_num = int(frame_num_blender * (30.0 / fps)) + + # This is a temporary keyframe, so we're not going to worry about converting everything + # Only the frame number to Plasma so we can go ahead and merge any rounded dupes + entry, data = keyframe_points.get(frame_num), None + if entry is None: + entry = {} + keyframe_points[frame_num] = entry + else: + data = entry.get(fcurve.data_path) + if data is None: + data = KeyFrameData() + data.frame_num = frame_num + data.frame_num_blender = frame_num_blender + entry[fcurve.data_path] = data + data.values[fcurve.array_index] = value + + # Now, we loop through our assembled keyframes and interpolate any missing data using the FCurves + fcurve_chans = { key: len(value) for key, value in grouped_fcurves.items() } + expected_values = sum(fcurve_chans.values()) + all_chans = frozenset(grouped_fcurves.keys()) + + # We will also do the final convert here as well... + final_keyframes = [] + + for frame_num in sorted(keyframe_points.copy().keys()): + keyframes = keyframe_points[frame_num] + frame_num_blender = next(iter(keyframes.values())).frame_num_blender + + # If any data_paths are missing, init a dummy + missing_channels = all_chans - frozenset(keyframes.keys()) + for chan in missing_channels: + dummy = KeyFrameData() + dummy.frame_num = frame_num + dummy.frame_num_blender = frame_num_blender + keyframes[chan] = dummy + + # Ensure all values are filled out. + num_values = sum(map(len, (i.values for i in keyframes.values()))) + if num_values != expected_values: + for chan, sorted_fcurves in grouped_fcurves.items(): + chan_keyframes = keyframes[chan] + chan_values = fcurve_chans[chan] + if len(chan_keyframes.values) == chan_values: + continue + for i in range(chan_values): + if i not in chan_keyframes.values: + fcurve = grouped_fcurves[chan][i] + if isinstance(fcurve, bpy.types.FCurve): + chan_keyframes.values[i] = fcurve.evaluate(chan_keyframes.frame_num_blender) + else: + # it's actually a default value! + chan_keyframes.values[i] = fcurve + + # All values are calculated! Now we convert the disparate key data into a single keyframe. + kwargs = { data_path: keyframe.values for data_path, keyframe in keyframes.items() } + final_keyframe = KeyFrameData() + final_keyframe.frame_num = frame_num + final_keyframe.frame_num_blender = frame_num_blender + final_keyframe.frame_time = frame_num / fps + value = convert(**kwargs) + if hasattr(value, "__len__"): + final_keyframe.in_tans = [0.0] * len(value) + final_keyframe.out_tans = [0.0] * len(value) + final_keyframe.values = value + else: + final_keyframe.in_tan = 0.0 + final_keyframe.out_tan = 0.0 + final_keyframe.value = value + final_keyframes.append(final_keyframe) + return final_keyframes + + + def _process_keyframes(self, fcurves, convert=None): """Groups all FCurves for the same frame together""" keyframe_data = type("KeyFrameData", (), {}) fps = self._bl_fps @@ -371,7 +732,7 @@ class AnimationConverter: keyframe.values = {} keyframes[frame_num] = keyframe idx = fcurve.array_index - keyframe.values[idx] = value + keyframe.values[idx] = value if convert is None else convert(value) # Calculate the bezier interpolation nonsense if fkey.interpolation == "BEZIER": diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 7881992..24763d2 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -207,6 +207,7 @@ class Exporter: sceneobject = self.mgr.find_create_object(plSceneObject, bl=bl_obj) self._export_actor(sceneobject, bl_obj) export_fn(sceneobject, bl_obj) + self.animation.convert_object_animations(bl_obj, sceneobject) # And now we puke out the modifiers... for mod in bl_obj.plasma_modifiers.modifiers: @@ -258,6 +259,8 @@ class Exporter: return True if bo.name in self.actors: return True + if self.animation.has_transform_animation(bo): + return True for mod in bo.plasma_modifiers.modifiers: if mod.enabled: diff --git a/korman/exporter/material.py b/korman/exporter/material.py index 3352112..33e8963 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -295,11 +295,12 @@ class MaterialConverter: return None def _export_layer_transform_animation(self, tex_slot, base_layer, fcurves): - pos_fcurves = [i for i in fcurves if i.data_path.find("offset") != -1] - scale_fcurves = [i for i in fcurves if i.data_path.find("scale") != -1] + path = tex_slot.path_from_id() + pos_path = "{}.offset".format(path) + scale_path = "{}.scale".format(path) # Plasma uses the controller to generate a matrix44... so we have to produce a leaf controller - ctrl = self._exporter().animation.make_matrix44_controller(pos_fcurves, scale_fcurves, tex_slot.offset, tex_slot.scale) + ctrl = self._exporter().animation.make_matrix44_controller(fcurves, pos_path, scale_path, tex_slot.offset, tex_slot.scale) return ctrl def _export_texture_type_environment_map(self, bo, layer, slot): diff --git a/korman/exporter/rtlight.py b/korman/exporter/rtlight.py index 01787e2..a253d27 100644 --- a/korman/exporter/rtlight.py +++ b/korman/exporter/rtlight.py @@ -40,11 +40,8 @@ class LightConverter: } def _convert_attenuation(self, bl, pl): - intens = bl.energy - if intens < 0: - intens = -intens - attenEnd = bl.distance * 2 if bl.use_sphere else bl.distance - + # If you change these calculations, be sure to update the AnimationConverter! + intens, attenEnd = self.convert_attenuation(bl) if bl.falloff_type == "CONSTANT": print(" Attenuation: No Falloff") pl.attenConst = intens @@ -54,18 +51,29 @@ class LightConverter: elif bl.falloff_type == "INVERSE_LINEAR": print(" Attenuation: Inverse Linear") pl.attenConst = 1.0 - pl.attenLinear = max(0.0, (intens * _FAR_POWER - 1.0) / attenEnd) + pl.attenLinear = self.convert_attenuation_linear(intens, attenEnd) pl.attenQuadratic = 0.0 pl.attenCutoff = attenEnd elif bl.falloff_type == "INVERSE_SQUARE": print(" Attenuation: Inverse Square") pl.attenConst = 1.0 pl.attenLinear = 0.0 - pl.attenQuadratic = max(0.0, (intens * _FAR_POWER - 1.0) / (attenEnd * attenEnd)) + pl.attenQuadratic = self.convert_attenuation_quadratic(intens, attenEnd) pl.attenCutoff = attenEnd else: raise BlenderOptionNotSupportedError(bl.falloff_type) + def convert_attenuation(self, lamp): + intens = abs(lamp.energy) + attenEnd = lamp.distance * 2 if lamp.use_sphere else lamp.distance + return (intens, attenEnd) + + def convert_attenuation_linear(self, intensity, end): + return max(0.0, (intensity * _FAR_POWER - 1.0) / end) + + def convert_attenuation_quadratic(self, intensity, end): + return max(0.0, (intensity * _FAR_POWER - 1.0) / pow(end, 2)) + def _convert_area_lamp(self, bl, pl): print(" [LimitedDirLightInfo '{}']".format(bl.name)) @@ -104,6 +112,7 @@ class LightConverter: self._converter_funcs[bl_light.type](bl_light, pl_light) # Light color nonsense + # Please note that these calculations are duplicated in the AnimationConverter energy = bl_light.energy if bl_light.use_negative: diff_color = [(0.0 - i) * energy for i in bl_light.color] @@ -175,6 +184,11 @@ class LightConverter: if projectors: self._export_rt_projector(bo, pl_light, projectors) + # If the lamp has any sort of animation attached, then it needs to be marked movable. + # Otherwise, Plasma may not use it for lighting. + if projectors or self._exporter().animation.is_animated(bo): + pl_light.setProperty(plLightInfo.kLPMovable, True) + # *Sigh* pl_light.sceneNode = self.mgr.get_scene_node(location=so.key.location) @@ -202,7 +216,6 @@ class LightConverter: else: state.miscFlags |= hsGMatState.kMiscOrthoProjection state.ZFlags |= hsGMatState.kZNoZWrite - pl_light.setProperty(plLightInfo.kLPMovable, True) pl_light.setProperty(plLightInfo.kLPCastShadows, False) if slot.blend_type == "ADD": diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index e500637..05e28d8 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -168,8 +168,7 @@ class PlasmaAnimCmdMsgNode(PlasmaMessageNode, bpy.types.Node): obj = bpy.data.objects.get(self.object_name, None) if obj is None: self.raise_error("invalid object: '{}'".format(self.object_name)) - anim = obj.plasma_modifiers.animation - if not anim.enabled: + if not exporter.animation.is_animated(obj): self.raise_error("invalid animation") group = obj.plasma_modifiers.animation_group if group.enabled: @@ -178,9 +177,7 @@ class PlasmaAnimCmdMsgNode(PlasmaMessageNode, bpy.types.Node): # (but obviously this is not wrong...) target = exporter.mgr.find_create_key(plMsgForwarder, bl=obj, name=group.key_name) else: - # remember, the AGModifier MUST exist first... so just in case... - exporter.mgr.find_create_key(plAGModifier, bl=obj, name=anim.key_name) - target = exporter.mgr.find_create_key(plAGMasterMod, bl=obj, name=anim.key_name) + _agmod_trash, target = exporter.animation.get_anigraph_keys(obj) else: material = bpy.data.materials.get(self.material_name, None) if material is None: diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index 77be132..7744c9e 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -24,14 +24,21 @@ def _convert_frame_time(frame_num): fps = bpy.context.scene.render.fps return frame_num / fps -def _get_blender_action(bo): - if bo.animation_data is None or bo.animation_data.action is None: - raise ExportError("Object '{}' has no Action to export".format(bo.name)) - if not bo.animation_data.action.fcurves: - raise ExportError("Object '{}' is animated but has no FCurves".format(bo.name)) - return bo.animation_data.action - -class PlasmaAnimationModifier(PlasmaModifierProperties): +class ActionModifier: + @property + def blender_action(self): + bo = self.id_data + if bo.animation_data is not None and bo.animation_data.action is not None: + return bo.animation_data.action + if bo.data is not None: + if bo.data.animation_data is not None and bo.data.animation_data.action is not None: + # we will not use this action for any animation logic. that must be stored on the Object + # datablock for simplicity's sake. + return None + raise ExportError("Object '{}' is not animated".format(bo.name)) + + +class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation" bl_category = "Animation" @@ -53,120 +60,49 @@ class PlasmaAnimationModifier(PlasmaModifierProperties): loop_end = StringProperty(name="Loop End", description="Marker indicating where the default loop ends") - @property - def requires_actor(self): - return True - def export(self, exporter, bo, so): - action = _get_blender_action(bo) - markers = action.pose_markers + action = self.blender_action - atcanim = exporter.mgr.find_create_object(plATCAnim, so=so, name=self.key_name) + atcanim = exporter.mgr.find_create_object(plATCAnim, so=so) atcanim.autoStart = self.auto_start atcanim.loop = self.loop - atcanim.name = "(Entire Animation)" - atcanim.start = _convert_frame_time(action.frame_range[0]) - atcanim.end = _convert_frame_time(action.frame_range[1]) # Simple start and loop info - initial_marker = markers.get(self.initial_marker) - if initial_marker is not None: - atcanim.initial = _convert_frame_time(initial_marker.frame) - else: - atcanim.initial = -1.0 - if self.loop: - loop_start = markers.get(self.loop_start) - if loop_start is not None: - atcanim.loopStart = _convert_frame_time(loop_start.frame) - else: - atcanim.loopStart = _convert_frame_time(action.frame_range[0]) - loop_end = markers.get(self.loop_end) - if loop_end is not None: - atcanim.loopEnd = _convert_frame_time(loop_end.frame) + if action is not None: + markers = action.pose_markers + initial_marker = markers.get(self.initial_marker) + if initial_marker is not None: + atcanim.initial = _convert_frame_time(initial_marker.frame) else: - atcanim.loopEnd = _convert_frame_time(action.frame_range[1]) - - # Marker points - for marker in markers: - atcanim.setMarker(marker.name, _convert_frame_time(marker.frame)) - - # Fixme? Not sure if we really need to expose this... - atcanim.easeInMin = 1.0 - atcanim.easeInMax = 1.0 - atcanim.easeInLength = 1.0 - atcanim.easeOutMin = 1.0 - atcanim.easeOutMax = 1.0 - atcanim.easeOutLength = 1.0 - - # Now for the animation data. We're mostly just going to hand this off to the controller code - matrix = bo.matrix_basis - applicator = plMatrixChannelApplicator() - applicator.enabled = True - applicator.channelName = bo.name - channel = plMatrixControllerChannel() - channel.controller = exporter.animation.convert_action2tm(action, matrix) - applicator.channel = channel - atcanim.addApplicator(applicator) - - # Decompose the matrix into the 90s-era 3ds max affine parts sillyness - # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... - affine = hsAffineParts() - affine.T = hsVector3(*matrix.to_translation()) - affine.K = hsVector3(*matrix.to_scale()) - affine.F = -1.0 if matrix.determinant() < 0.0 else 1.0 - rot = matrix.to_quaternion() - affine.Q = utils.quaternion(rot) - rot.normalize() - affine.U = utils.quaternion(rot) - channel.affine = affine - - # We need both an AGModifier and an AGMasterMod - # NOTE: mandatory order--otherwise the animation will not work in game! - agmod = exporter.mgr.find_create_object(plAGModifier, so=so, name=self.key_name) - agmod.channelName = bo.name - agmaster = exporter.mgr.find_create_object(plAGMasterMod, so=so, name=self.key_name) - agmaster.addPrivateAnim(atcanim.key) - - @property - def key_name(self): - return "{}_(Entire Animation)".format(self.id_data.name) - - def _make_physical_movable(self, so): - sim = so.sim - if sim is not None: - sim = sim.object - sim.setProperty(plSimulationInterface.kPhysAnim, True) - phys = sim.physical.object - phys.setProperty(plSimulationInterface.kPhysAnim, True) - - # If the mass is zero, then we will fail to animate. Fix that. - if phys.mass == 0.0: - phys.mass = 1.0 - - # set kPinned so it doesn't fall through - sim.setProperty(plSimulationInterface.kPinned, True) - phys.setProperty(plSimulationInterface.kPinned, True) - - # Do the same for children objects - for child in so.coord.object.children: - self.make_physical_movable(child.object) - - def post_export(self, exporter, bo, so): - # If this object has a physical, we need to tell the simulation iface that it can be animated - self._make_physical_movable(so) + atcanim.initial = -1.0 + if self.loop: + loop_start = markers.get(self.loop_start) + if loop_start is not None: + atcanim.loopStart = _convert_frame_time(loop_start.frame) + else: + atcanim.loopStart = atcanim.start + loop_end = markers.get(self.loop_end) + if loop_end is not None: + atcanim.loopEnd = _convert_frame_time(loop_end.frame) + else: + atcanim.loopEnd = atcanim.end + else: + if self.loop: + atcanim.loopStart = atcanim.start + atcanim.loopEnd = atcanim.end class AnimGroupObject(bpy.types.PropertyGroup): - object_name = StringProperty(name="Child", + object_name = StringProperty(name="Child Animation", description="Object whose action is a child animation") -class PlasmaAnimationGroupModifier(PlasmaModifierProperties): +class PlasmaAnimationGroupModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation_group" pl_depends = {"animation"} bl_category = "Animation" - bl_label = "Group" + bl_label = "Group Master" bl_description = "Defines related animations" bl_icon = "GROUP" @@ -176,17 +112,14 @@ class PlasmaAnimationGroupModifier(PlasmaModifierProperties): active_child_index = IntProperty(options={"HIDDEN"}) def export(self, exporter, bo, so): - action = _get_blender_action(bo) - key_name = bo.plasma_modifiers.animation.key_name - - # See above... AGModifier must always be inited first... - agmod = exporter.mgr.find_create_object(plAGModifier, so=so, name=key_name) + if not exporter.animation.is_animated(bo): + raise ExportError("'{}': Object is not animated".format(bo.name)) # The message forwarder is the guy that makes sure that everybody knows WTF is going on msgfwd = exporter.mgr.find_create_object(plMsgForwarder, so=so, name=self.key_name) # Now, this is da swhiz... - agmaster = exporter.mgr.find_create_object(plAGMasterMod, so=so, name=key_name) + agmod, agmaster = exporter.animation.get_anigraph_objects(bo, so) agmaster.msgForwarder = msgfwd.key agmaster.isGrouped, agmaster.isGroupMaster = True, True for i in self.children: @@ -204,9 +137,8 @@ class PlasmaAnimationGroupModifier(PlasmaModifierProperties): msg = "Animation Group '{}' specifies an object '{}' with no Plasma Animation modifier. Ignoring..." exporter.report.warn(msg.format(self.key_name, i.object_name), indent=2) continue - child_agmod = exporter.mgr.find_create_key(plAGModifier, bl=child_bo, name=child_animation.key_name) - child_agmaster = exporter.mgr.find_create_key(plAGMasterMod, bl=child_bo, name=child_animation.key_name) - msgfwd.addForwardKey(child_agmaster) + child_agmod, child_agmaster = exporter.animation.get_anigraph_objects(bo=child_bo) + msgfwd.addForwardKey(child_agmaster.key) msgfwd.addForwardKey(agmaster.key) @property @@ -223,7 +155,7 @@ class LoopMarker(bpy.types.PropertyGroup): description="Marker name from whence the loop ends") -class PlasmaAnimationLoopModifier(PlasmaModifierProperties): +class PlasmaAnimationLoopModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation_loop" pl_depends = {"animation"} @@ -238,11 +170,12 @@ class PlasmaAnimationLoopModifier(PlasmaModifierProperties): active_loop_index = IntProperty(options={"HIDDEN"}) def export(self, exporter, bo, so): - action = _get_blender_action(bo) + action = self.blender_action + if action is None: + raise ExportError("'{}': No object animation data".format(bo.name)) markers = action.pose_markers - key_name = bo.plasma_modifiers.animation.key_name - atcanim = exporter.mgr.find_create_object(plATCAnim, so=so, name=key_name) + atcanim = exporter.mgr.find_create_object(plATCAnim, so=so) for loop in self.loops: start = markers.get(loop.loop_start) end = markers.get(loop.loop_end) diff --git a/korman/properties/modifiers/physics.py b/korman/properties/modifiers/physics.py index 53a5198..c52c95e 100644 --- a/korman/properties/modifiers/physics.py +++ b/korman/properties/modifiers/physics.py @@ -84,6 +84,32 @@ class PlasmaCollider(PlasmaModifierProperties): if self.terrain: physical.LOSDBs |= plSimDefs.kLOSDBAvatarWalkable + def _make_physical_movable(self, so): + sim = so.sim + if sim is not None: + sim = sim.object + phys = sim.physical.object + _set_phys_prop(plSimulationInterface.kPhysAnim, sim, phys) + + # If the mass is zero, then we will fail to animate. Fix that. + if phys.mass == 0.0: + phys.mass = 1.0 + + # set kPinned so it doesn't fall through + _set_phys_prop(plSimulationInterface.kPinned, sim, phys) + + # Do the same for child objects + for child in so.coord.object.children: + self._make_physical_movable(child.object) + + def post_export(self, exporter, bo, so): + test_bo = bo + while test_bo is not None: + if exporter.animation.has_transform_animation(test_bo): + self._make_physical_movable(so) + break + test_bo = test_bo.parent + @property def key_name(self): return "{}_Collision".format(self.id_data.name) diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index a641141..4ed90cf 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -131,7 +131,7 @@ class PlasmaSound(bpy.types.PropertyGroup): volume = IntProperty(name="Volume", description="Volume to play the sound", min=0, max=100, default=100, - options=set(), + options={"ANIMATABLE"}, subtype="PERCENTAGE") fade_in = PointerProperty(type=PlasmaSfxFade, options=set()) @@ -344,20 +344,35 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): if i.sound_data and i.enabled: i.convert_sound(exporter, so, winaud) - def get_sound_indices(self, name): + def get_sound_indices(self, name=None, sound=None): """Returns the index of the given sound in the plWin32Sound. This is needed because stereo 3D sounds export as two mono sound objects -- wheeeeee""" + assert name or sound idx = 0 - for i in self.sounds: - if i.name == name: - yield idx - if i.is_3d_stereo: - yield idx + 1 - break + + if name is None: + for i in self.sounds: + if i == sound: + yield idx + if i.is_3d_stereo: + yield idx + 1 + break + else: + idx += 2 if i.is_3d_stereo else 1 else: - idx += 2 if i.is_3d_stereo else 1 - else: - raise ValueError(name) + raise LookupError(sound) + + if sound is None: + for i in self.sounds: + if i.name == name: + yield idx + if i.is_3d_stereo: + yield idx + 1 + break + else: + idx += 2 if i.is_3d_stereo else 1 + else: + raise ValueError(name) @classmethod def register(cls): diff --git a/korman/ui/modifiers/anim.py b/korman/ui/modifiers/anim.py index e9b2fd5..402b7c2 100644 --- a/korman/ui/modifiers/anim.py +++ b/korman/ui/modifiers/anim.py @@ -15,16 +15,19 @@ import bpy -def _check_for_anim(layout, context): - if context.object.animation_data is None or context.object.animation_data.action is None: +def _check_for_anim(layout, modifier): + try: + action = modifier.blender_action + except: layout.label("Object has no animation data", icon="ERROR") - return False - return True + return None + else: + return action if action is not None else False def animation(modifier, layout, context): - if not _check_for_anim(layout, context): + action = _check_for_anim(layout, modifier) + if action is None: return - action = context.object.animation_data.action split = layout.split() col = split.column() @@ -32,20 +35,24 @@ def animation(modifier, layout, context): col = split.column() col.prop(modifier, "loop") - layout.prop_search(modifier, "initial_marker", action, "pose_markers", icon="PMARKER") - col = layout.column() - col.enabled = modifier.loop - col.prop_search(modifier, "loop_start", action, "pose_markers", icon="PMARKER") - col.prop_search(modifier, "loop_end", action, "pose_markers", icon="PMARKER") + if action: + layout.prop_search(modifier, "initial_marker", action, "pose_markers", icon="PMARKER") + col = layout.column() + col.enabled = modifier.loop + col.prop_search(modifier, "loop_start", action, "pose_markers", icon="PMARKER") + col.prop_search(modifier, "loop_end", action, "pose_markers", icon="PMARKER") class GroupListUI(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): - layout.prop_search(item, "object_name", bpy.data, "objects", icon="ACTION") + label = item.object_name if item.object_name else "[No Child Specified]" + icon = "ACTION" if item.object_name else "ERROR" + layout.label(text=label, icon=icon) def animation_group(modifier, layout, context): - if not _check_for_anim(layout, context): + action = _check_for_anim(layout, modifier) + if action is None: return row = layout.row() @@ -60,6 +67,9 @@ def animation_group(modifier, layout, context): op.collection = "children" op.index = modifier.active_child_index + if modifier.children: + layout.prop_search(modifier.children[modifier.active_child_index], "object_name", bpy.data, "objects", icon="ACTION") + class LoopListUI(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): @@ -67,7 +77,11 @@ class LoopListUI(bpy.types.UIList): def animation_loop(modifier, layout, context): - if not _check_for_anim(layout, context): + action = _check_for_anim(layout, modifier) + if action is False: + layout.label("Object must be animated, not ObData", icon="ERROR") + return + elif action is None: return row = layout.row() @@ -86,7 +100,6 @@ def animation_loop(modifier, layout, context): # Modify the loop points if modifier.loops: - action = context.object.animation_data.action loop = modifier.loops[modifier.active_loop_index] layout.prop_search(loop, "loop_start", action, "pose_markers", icon="PMARKER") layout.prop_search(loop, "loop_end", action, "pose_markers", icon="PMARKER")