diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index 2188bf8..e34eef6 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -272,30 +272,24 @@ class AnimationConverter: convert_volume = lambda x: math.log10(max(.01, x / 100.0)) * 20.0 - for sound in soundemit.sounds: - path = "{}.volume".format(sound.path_from_id()) + for i, sound in enumerate(filter(lambda x: x.enabled, soundemit.sounds)): + path = sound.path_from_id("volume") 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. - # To make matters worse, the way that these keyframes are stored can cause - # the animation to evaluate to a no-op. Be ready for that. - controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume, start=start, end=end) - if controller is not None: - channel = plScalarControllerChannel() - channel.controller = controller - applicator.channel = channel - yield applicator - else: - self._exporter().report.warn(f"[{sound.sound.name}]: Volume animation evaluated to zero keyframes!") - break + applicator = plSoundVolumeApplicator() + applicator.channelName = name + applicator.index = i + + controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume, start=start, end=end) + if controller is not None: + channel = plScalarControllerChannel() + channel.controller = controller + applicator.channel = channel + yield applicator + else: + self._exporter().report.warn(f"[{sound.sound.name}]: Volume animation evaluated to zero keyframes!") def _convert_spot_lamp_animation(self, name, fcurves, lamp, start, end): if not fcurves: diff --git a/korman/helpers.py b/korman/helpers.py index 31c1d20..59b5618 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -29,6 +29,27 @@ def bmesh_from_object(bl): finally: mesh.free() +@contextmanager +def duplicate_object(bl, remove_on_success=False): + with UiHelper(bpy.context): + for i in bpy.data.objects: + i.select = False + bl.select = True + bpy.context.scene.objects.active = bl + bpy.ops.object.duplicate() + dupe_object = bpy.context.active_object + + dupe_name = dupe_object.name + try: + yield dupe_object + except Exception: + if dupe_name in bpy.data.objects: + bpy.data.objects.remove(dupe_object) + raise + finally: + if remove_on_success and dupe_name in bpy.data.objects: + bpy.data.objects.remove(dupe_object) + class GoodNeighbor: """Leave Things the Way You Found Them! (TM)""" @@ -46,6 +67,16 @@ class GoodNeighbor: setattr(cls, attr, value) +@contextmanager +def TemporaryCollectionItem(collection): + item = collection.add() + 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) + class TemporaryObject: def __init__(self, obj, remove_func): self._obj = obj diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index c36f39a..bed7009 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -830,37 +830,46 @@ class PlasmaSoundMsgNode(idprops.IDPropObjectMixin, PlasmaMessageWithCallbacksNo # Remember that 3D stereo sounds are exported as two emitters... # But, if we only have one sound attached, who cares, we can just address the message to all - audible_key = exporter.mgr.find_create_key(plAudioInterface, bl=self.emitter_object) - indices = (-1,) if not self.sound_name or len(soundemit.sounds) == 1 else soundemit.get_sound_indices(self.sound_name) - for idx in indices: - msg = plSoundMsg() - msg.addReceiver(audible_key) - msg.index = idx - - # NOTE: There are a number of commands in Plasma's enumeration that do nothing. - # This is what I determine to be the most useful and functional subset... - # Please see plAudioInterface::MsgReceive for more details. - if self.go_to == "BEGIN": - msg.setCmd(plSoundMsg.kGoToTime) - msg.time = 0.0 - elif self.go_to == "TIME": - msg.setCmd(plSoundMsg.kGoToTime) - msg.time = self.time - - if self.volume == "MUTE": - msg.setCmd(plSoundMsg.kSetVolume) - msg.volume = 0.0 - elif self.volume == "CUSTOM": - msg.setCmd(plSoundMsg.kSetVolume) - msg.volume = self.volume_pct / 100.0 - - if self.looping != "CURRENT": - msg.setCmd(getattr(plSoundMsg, self.looping)) - if self.action != "CURRENT": - msg.setCmd(getattr(plSoundMsg, self.action)) - - # Because we might be giving two messages here... - yield msg + msg = plSoundMsg() + sound_keys = tuple(soundemit.get_sound_keys(exporter, self.sound_name)) + indices = frozenset((i[1] for i in sound_keys)) + + if indices: + assert len(indices) == 1, "Only one sound index should result from a sound emitter" + msg.index = next(iter(indices)) + else: + msg.index = -1 + for i in sound_keys: + msg.addReceiver(i[0]) + + # NOTE: There are a number of commands in Plasma's enumeration that do nothing. + # This is what I determine to be the most useful and functional subset... + # Please see plAudioInterface::MsgReceive for more details. + if self.go_to == "BEGIN": + msg.setCmd(plSoundMsg.kGoToTime) + msg.time = 0.0 + elif self.go_to == "TIME": + msg.setCmd(plSoundMsg.kGoToTime) + msg.time = self.time + + if self.volume == "MUTE": + msg.setCmd(plSoundMsg.kSetVolume) + msg.volume = 0.0 + elif self.volume == "CUSTOM": + msg.setCmd(plSoundMsg.kSetVolume) + msg.volume = self.volume_pct / 100.0 + + if self.looping != "CURRENT": + msg.setCmd(getattr(plSoundMsg, self.looping)) + if self.action != "CURRENT": + sound = soundemit.sounds.get(self.sound_name, None) + if sound is not None and sound.is_3d_stereo: + exporter.report.warn(f"'{self.id_data.name}' Node '{self.name}': 3D Stereo sounds should not be started or stopped by messages - they may get out of sync.") + msg.setCmd(getattr(plSoundMsg, self.action)) + + # This used to potentially result in multiple messages. Not anymore! + # However, I'm leaving it as a yield for now to avoid potentially breaking something. + yield msg def draw_buttons(self, context, layout): layout.prop(self, "emitter_object") @@ -880,7 +889,17 @@ class PlasmaSoundMsgNode(idprops.IDPropObjectMixin, PlasmaMessageWithCallbacksNo if self.go_to == "TIME": layout.prop(self, "time") - layout.prop(self, "action") + if not random and self.emitter_object is not None: + soundemit = self.emitter_object.plasma_modifiers.soundemit + sound = soundemit.sounds.get(self.sound_name, None) + action_on_3d_stereo = sound is not None and sound.is_3d_stereo and self.action != "CURRENT" + + layout.alert = action_on_3d_stereo + layout.prop(self, "action") + layout.alert = False + else: + layout.prop(self, "action") + if self.volume == "CUSTOM": layout.prop(self, "volume_pct") if not random: diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index 5cd1d06..df96850 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -157,6 +157,7 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties): class AnimGroupObject(idprops.IDPropObjectMixin, bpy.types.PropertyGroup): + enabled = BoolProperty(name="Enabled", default=True) child_anim = PointerProperty(name="Child Animation", description="Object whose action is a child animation", type=bpy.types.Object, @@ -235,7 +236,7 @@ class PlasmaAnimationGroupModifier(ActionModifier, PlasmaModifierProperties): agmod, agmaster = exporter.animation.get_anigraph_objects(bo, so) agmaster.msgForwarder = msgfwd.key agmaster.isGrouped, agmaster.isGroupMaster = True, True - for i in self.children: + for i in filter(lambda x: x.enabled, self.children): child_bo = i.child_anim if child_bo is None: msg = "Animation Group '{}' specifies an invalid object. Ignoring..." diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index 8e3b005..4409d53 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -13,17 +13,21 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from __future__ import annotations + import bpy from bpy.props import * from bpy.app.handlers import persistent from contextlib import contextmanager import math from PyHSPlasma import * +from typing import * from ... import korlib from .base import PlasmaModifierProperties from .physics import surface_types from ...exporter import ExportError +from ...helpers import duplicate_object, GoodNeighbor, TemporaryCollectionItem from ... import idprops _randomsound_modes = { @@ -520,47 +524,187 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): sounds = CollectionProperty(type=PlasmaSound) active_sound_index = IntProperty(options={"HIDDEN"}) + stereize_left = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) + stereize_right = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) + + def sanity_check(self): + modifiers = self.id_data.plasma_modifiers + + # Sound emitters can potentially export sounds to more than one emitter SceneObject. Currently, + # this happens for 3D stereo sounds. That means that any modifier that expects for all of + # this emitter's sounds to be attached to this plAudioInterface might have a bad time. + if self.have_3d_stereo and modifiers.random_sound.enabled: + raise ExportError(f"{self.id_data.name}: Random Sound modifier cannot be applied to a Sound Emitter with 3D Stereo sounds.") + + @contextmanager + def _generate_stereized_emitter(self, exporter, bo: bpy.types.Object, channel: str, attr: str): + # Duplicate the current sound emitter as a non-linked object so that we have all the + # information that the parent emitter has, but we're free to turn off things as needed. + with duplicate_object(bo) as emitter_obj: + emitter_obj.location = (0.0, 0.0, 0.0) + emitter_obj.name = f"{bo.name}_Stereo-Ize:{channel}" + emitter_obj.parent = bo + + # In case some bozo is using a visual mesh as a sound emitter, clear the materials + # off the duplicate to prevent it from being visible in the world. + if emitter_obj.type == "MESH": + emitter_obj.data.materials.clear() + + # We want to allow animations and sounds to export from the new emitter. + bad_mods = filter( + lambda x: x.pl_id not in {"animation", "soundemit"}, + emitter_obj.plasma_modifiers.modifiers + ) + for i in bad_mods: + # HACK: You can't set the enabled property. + i.display_order = -1 + + # But only 3D stereo sounds! + soundemit_mod = emitter_obj.plasma_modifiers.soundemit + for sound in soundemit_mod.sounds: + if sound.is_3d_stereo: + sound.channel = set(channel) + else: + sound.enabled = False + + # And only sound volume animations! + if emitter_obj.animation_data is not None and emitter_obj.animation_data.action is not None: + action = emitter_obj.animation_data.action + volume_paths = frozenset((i.path_from_id("volume") for i in soundemit_mod.sounds if i.enabled)) + toasty_fcurves = [i for i, fcurve in enumerate(action.fcurves) if fcurve.data_path not in volume_paths] + for i in reversed(toasty_fcurves): + action.fcurves.remove(i) + + # Again, only sound volume animations, which are handled above. + emitter_obj_data = emitter_obj.data + if emitter_obj_data is not None and emitter_obj_data.animation_data is not None and emitter_obj_data.animation_data.action is not None: + emitter_obj_data.animation_data.action.fcurves.clear() + + # Temporarily save a pointer to this generated emitter object so that the parent soundemit + # modifier can redirect requests to 3D sounds to the generated emitters. + setattr(self, attr, emitter_obj) + try: + yield emitter_obj + finally: + self.property_unset(attr) + + def _find_animation_groups(self, bo: bpy.types.Object): + is_anim_group = lambda x: ( + x is not bo and + x.plasma_object.enabled and + x.plasma_modifiers.animation_group.enabled + ) + for i in filter(is_anim_group, bpy.data.objects): + group = i.plasma_modifiers.animation_group + for child in group.children: + if child.child_anim == self.id_data: + yield child + + def _add_child_animation(self, exporter, group, bo: bpy.types.Object, temporary=False): + if temporary: + child = exporter.exit_stack.enter_context(TemporaryCollectionItem(group.children)) + else: + child = group.children.add() + child.child_anim = bo + + def pre_export(self, exporter, bo: bpy.types.Object): + # Stereo 3D sounds are a very, very special case. We need to export mono sound sources. + # However, to get Plasma's Stereo-Ize feature to work, they need to be completely separate + # objects that the engine can move around itself. Those need to be duplicates of this + # blender object so that all animation data and whatnot remains. + if self.have_3d_stereo: + toggle = exporter.exit_stack.enter_context(GoodNeighbor()) + + # Find any animation groups that we're a part of - we need to be a member of those, + # or create one *if* any animations + yield self._generate_stereized_emitter(exporter, bo, "L", "stereize_left") + yield self._generate_stereized_emitter(exporter, bo, "R", "stereize_right") + + # If some animation data persisted on the new emitters, then we need to make certain + # that those animations are targetted by anyone trying to control us. That's an + # animation group modifier (plMsgForwarder) for anyone playing along at home. + if self.stereize_left.plasma_object.has_animation_data or self.stereize_right.plasma_object.has_animation_data: + my_anim_groups = list(self._find_animation_groups(bo)) + + # If no one contains this sound emitter, then we need to be an animation group. + if not my_anim_groups: + group = bo.plasma_modifiers.animation_group + if not group.enabled: + toggle.track(group, "display_order", sum((1 for i in bo.plasma_modifiers.modifiers))) + for i in group.children: + toggle.track(i, "enabled", False) + my_anim_groups.append(group) + + # Now that we have the animation groups, feed in the generated emitter objects + # as ephemeral child animations. They should be removed from the modifier when the + # export finishes. + for anim_group in my_anim_groups: + self._add_child_animation(exporter, anim_group, self.stereize_left, True) + self._add_child_animation(exporter, anim_group, self.stereize_right, True) + + # Temporarily disable the 3D stereo sounds on this emitter during the export - so + # this emitter will export everything that isn't 3D stereo. + for i in filter(lambda x: x.is_3d_stereo, self.sounds): + toggle.track(i, "enabled", False) + def export(self, exporter, bo, so): - winaud = exporter.mgr.find_create_object(plWinAudible, so=so, name=self.key_name) - winaud.sceneNode = exporter.mgr.get_scene_node(so.key.location) - aiface = exporter.mgr.find_create_object(plAudioInterface, so=so, name=self.key_name) - aiface.audible = winaud.key - - # Pass this off to each individual sound for conversion - for i in self.sounds: - if i.enabled: + if any((i.enabled for i in self.sounds)): + winaud = exporter.mgr.find_create_object(plWinAudible, so=so, name=self.key_name) + winaud.sceneNode = exporter.mgr.get_scene_node(so.key.location) + aiface = exporter.mgr.find_create_object(plAudioInterface, so=so, name=self.key_name) + aiface.audible = winaud.key + + # Pass this off to each individual sound for conversion + for i in filter(lambda x: x.enabled, self.sounds): i.convert_sound(exporter, so, winaud) - 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""" + # Back to our faked emitters for 3D stereo sounds... Create the stereo-ize object + # that will cause Plasma to move the object around in the 3D world. In the future, + # this should probably be split out to a separate modifier. + if self.stereize_left and self.stereize_right: + self._convert_stereize(exporter, self.stereize_left, "L") + self._convert_stereize(exporter, self.stereize_right, "R") + + def post_export(self, exporter, bo: bpy.types.Object, so: plSceneObject): + if self.stereize_left and self.stereize_right: + self._handle_stereize_lfm(exporter, self.stereize_left, so) + self._handle_stereize_lfm(exporter, self.stereize_right, so) + + def _convert_stereize(self, exporter, bo: bpy.types.Object, channel: str) -> None: + # TODO: This should probably be moved into a Stereo-Ize modifier of some sort + stereoize = exporter.mgr.find_create_object(plStereizer, bl=bo) + stereoize.setFlag(plStereizer.kLeftChannel, channel == "L") + stereoize.ambientDist = 50.0 + stereoize.sepDist = (5.0, 100.0) + stereoize.transition = 25.0 + stereoize.tanAng = math.radians(30.0) + + def _handle_stereize_lfm(self, exporter, child_bo: bpy.types.Object, parent_so: plSceneObject) -> None: + # TODO: This should probably be moved into a Stereo-Ize modifier of some sort + stereizer = exporter.mgr.find_object(plStereizer, bl=child_bo) + for lfm_key in filter(lambda x: isinstance(x.object, plLineFollowMod), parent_so.modifiers): + stereizer.setFlag(plStereizer.kHasMaster, True) + lfm_key.object.addStereizer(stereizer.key) + + def get_sound_keys(self, exporter, name=None, sound=None) -> Iterator[Tuple[plKey, int]]: assert name or sound - idx = 0 - - 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: - 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: + sound = next((i for i in self.sounds if i._sound_name == name), None) + if sound is None: raise ValueError(name) + if sound.is_3d_stereo: + yield from self.stereize_left.plasma_modifiers.soundemit.get_sound_keys(exporter, sound.name) + yield from self.stereize_right.plasma_modifiers.soundemit.get_sound_keys(exporter, sound.name) + else: + for i, j in enumerate(filter(lambda x: x.enabled, self.sounds)): + if sound == j: + yield exporter.mgr.find_create_key(plAudioInterface, bl=self.id_data), i + + @property + def have_3d_stereo(self) -> bool: + return any((i.is_3d_stereo for i in self.sounds if i.enabled)) + @property def requires_actor(self): return True