Browse Source

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.
pull/364/head
Adam Johnson 1 year ago
parent
commit
6afa39e94a
Signed by: Hoikas
GPG Key ID: 0B6515D6FF6F271E
  1. 34
      korman/exporter/animation.py
  2. 31
      korman/helpers.py
  3. 68
      korman/nodes/node_messages.py
  4. 3
      korman/properties/modifiers/anim.py
  5. 212
      korman/properties/modifiers/sound.py

34
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:

31
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

68
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")

3
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..."

212
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 <http://www.gnu.org/licenses/>.
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

Loading…
Cancel
Save