Browse Source

Merge pull request #364 from Hoikas/stereize

Improve 3D stereo sound emitters.
pull/372/head
Adam Johnson 1 year ago committed by GitHub
parent
commit
0d892fd387
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 34
      korman/exporter/animation.py
  2. 31
      korman/helpers.py
  3. 83
      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

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

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