From 6afa39e94a5084f447314a6ed42b8ea1eb4b8e07 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 5 Mar 2023 16:29:06 -0500 Subject: [PATCH] Improve 3D stereo sound emitters. In an attempt to address #359, I separated 3D stereo sounds into separate emitter scene objects and allow the engine to position them around the listener such that the left channel is actually on the left of the listener (and the same for the right channel). Unfortunately, this did not fix the bug in question. However, the code that interfaces with sounds from the outside is now much simpler, and the improved behavior is a win, IMO, so let's keep this. --- korman/exporter/animation.py | 34 ++--- korman/helpers.py | 31 ++++ korman/nodes/node_messages.py | 68 +++++---- korman/properties/modifiers/anim.py | 3 +- korman/properties/modifiers/sound.py | 212 ++++++++++++++++++++++----- 5 files changed, 262 insertions(+), 86 deletions(-) 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..5e6e7c4 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -830,37 +830,43 @@ 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": + 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") 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