mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-08-01 08:18:20 -04:00
// SkinnedModelRenderer - the only new entry point for speaking
SoundHandle SpeakSound( SoundEvent sound, GameObject mouth = null )
SoundHandle SpeakSound( SoundFile sound, float volume = 1, float pitch = 1, GameObject mouth = null )
// SoundFile - the authored lipsync track, read from the compiled vsnd
VisemeTrack Visemes { get; } // null = no track (or not loaded yet, see IsValidForPlayback)
// VisemeTrack - visemes over time, authored in the sound editor
IReadOnlyList<VisemeFrame> Frames { get; }
float Duration { get; }
bool Sample( float time, Span<float> weights )
// VisemeFrame - one viseme span (struct: Viseme, StartTime, EndTime)
// Visemes - the standard 15-viseme table, OVR order
static IReadOnlyList<string> MorphNames { get; }
static int Count { get; }
// MorphCollection - the one shared viseme blend (sounds, voice chat, editor preview)
void ApplyVisemes( ReadOnlySpan<float> visemeWeights, float scale = 1, float smoothTime = 0 )
// ModelMorphs - per-model viseme weight table, cached managed-side (no interop on lookups)
float GetVisemeMorph( int viseme, int morph )
float GetVisemeMorph( string viseme, int morph )
// ResourceCompileContext - inject managed data into natively-compiled resources
void WriteBlock( string blockName, ReadOnlySpan<byte> data ) // four-char block names, eg "LIPS"
void WriteBlockJson( string blockName, object obj )
JsonObject ReadMeta() // the asset's .meta, registers a compile dependency
T ReadMeta<T>( string key, T defaultValue = default )
// ResourceLoadContext - read blocks back when the resource loads (Resource.OnLoaded, engine-internal for now)
// public ref struct, only valid during the load callback
bool Exists( string blockName )
ReadOnlySpan<byte> ReadData( string blockName )
string ReadString( string blockName )
T ReadJson<T>( string blockName, T defaultValue = default )
239 lines
7.0 KiB
C#
239 lines
7.0 KiB
C#
namespace Sandbox;
|
|
|
|
public partial class SkinnedModelRenderer
|
|
{
|
|
/// <summary>
|
|
/// Drive viseme morphs from speaking voices. Turn off to check whether facial
|
|
/// movement is coming from lipsync or something else.
|
|
/// </summary>
|
|
[ConVar( "snd_voice_lipsync" )]
|
|
internal static bool VoiceLipSyncEnabled { get; set; } = true;
|
|
|
|
SoundHandle _voice;
|
|
SoundFile _voiceSoundFile;
|
|
|
|
/// <summary>
|
|
/// The sound this model is currently speaking, set by
|
|
/// <see cref="SpeakSound(SoundEvent, GameObject)"/>. While this is playing we
|
|
/// drive the model's viseme morphs from it, so the mouth moves along with the words.
|
|
/// </summary>
|
|
internal SoundHandle Voice
|
|
{
|
|
get => _voice;
|
|
set
|
|
{
|
|
if ( _voice == value )
|
|
return;
|
|
|
|
_voice?.Stop();
|
|
_voice?.Dispose();
|
|
_voice = default;
|
|
|
|
_voice = value;
|
|
_voiceSoundFile = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Play a sound as this model's voice. On top of regular sound playback, the
|
|
/// model will lipsync to it - if the sound has a viseme track (authored in the
|
|
/// sound editor) we play that back, otherwise the audio is analyzed as it
|
|
/// plays. Either way the mouth moves, assuming the model has viseme morphs.
|
|
/// </summary>
|
|
/// <param name="sound">The sound to speak.</param>
|
|
/// <param name="mouth">Optional object to play the sound from, like a mouth bone. Defaults to our GameObject.</param>
|
|
public SoundHandle SpeakSound( SoundEvent sound, GameObject mouth = null )
|
|
{
|
|
var handle = Sound.Play( sound, out var soundFile );
|
|
|
|
if ( handle.IsValid() )
|
|
{
|
|
var host = mouth ?? GameObject;
|
|
handle.Position = host.WorldPosition;
|
|
handle.Parent = host;
|
|
handle.FollowParent = true;
|
|
}
|
|
|
|
Voice = handle;
|
|
_voiceSoundFile = soundFile;
|
|
|
|
return Voice;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Play a specific sound file as this model's voice. Useful when you've picked
|
|
/// the file yourself - to keep it in sync across the network, or because you
|
|
/// generated it. See <see cref="SpeakSound(SoundEvent, GameObject)"/>.
|
|
/// </summary>
|
|
/// <param name="sound">The sound file to speak.</param>
|
|
/// <param name="volume">Playback volume.</param>
|
|
/// <param name="pitch">Playback pitch.</param>
|
|
/// <param name="mouth">Optional object to play the sound from, like a mouth bone. Defaults to our GameObject.</param>
|
|
public SoundHandle SpeakSound( SoundFile sound, float volume = 1.0f, float pitch = 1.0f, GameObject mouth = null )
|
|
{
|
|
if ( !sound.IsValid() )
|
|
return default;
|
|
|
|
#pragma warning disable CA2000 // Dispose objects before losing scope
|
|
var handle = Sound.PlayFile( sound, volume, pitch );
|
|
#pragma warning restore CA2000
|
|
if ( !handle.IsValid() )
|
|
return default;
|
|
|
|
var host = mouth ?? GameObject;
|
|
handle.Position = host.WorldPosition;
|
|
handle.Parent = host;
|
|
handle.FollowParent = true;
|
|
|
|
Voice = handle;
|
|
_voiceSoundFile = sound;
|
|
|
|
return Voice;
|
|
}
|
|
|
|
// How fast morphs chase their target weight. The LipSync component pipes its
|
|
// smoothing knob through here.
|
|
internal float VoiceMorphSmoothTime = 0.1f;
|
|
|
|
// Realtime analysis produces weaker weights than authored tracks, scale them
|
|
// up. The LipSync component pipes its scale knob through here.
|
|
internal float VoiceRealtimeScale = 1.5f;
|
|
|
|
// Realtime analysis is noisy - ignore viseme weights below this, so silence
|
|
// and the noise floor don't twitch the face
|
|
const float VoiceRealtimeDeadzone = 0.15f;
|
|
|
|
// The model whose viseme morphs we're driving, so we can let go of them
|
|
// when the voice stops or the model changes. The morph weights themselves
|
|
// live on Model.Morphs, shared by everything that lipsyncs.
|
|
Model _voiceMorphModel;
|
|
|
|
/// <summary>
|
|
/// True if there's a voice to lipsync, or morphs we're still driving that
|
|
/// need letting go of. The animation system skips us entirely otherwise.
|
|
/// </summary>
|
|
internal bool VoiceActive => _voice is not null || _voiceMorphModel is not null;
|
|
|
|
/// <summary>
|
|
/// Drive viseme morphs from the current <see cref="Voice"/>. Called on the main
|
|
/// thread each frame before animation updates, so the weights we set here get
|
|
/// composited in the same frame's bone/morph update.
|
|
/// </summary>
|
|
internal void UpdateVoice()
|
|
{
|
|
// Voice stopped - ease the mouth back to the animation and let go
|
|
if ( !_voice.IsValid() || _voice.Finished )
|
|
{
|
|
Voice = null;
|
|
|
|
if ( _voiceMorphModel is not null )
|
|
{
|
|
var morphModel = SceneModel;
|
|
if ( morphModel.IsValid() )
|
|
ClearVoiceMorphs( morphModel );
|
|
else
|
|
_voiceMorphModel = null;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var sceneModel = SceneModel;
|
|
if ( !sceneModel.IsValid() )
|
|
return;
|
|
|
|
// Lipsync turned off - let go of the face but keep the voice, and stop
|
|
// paying for realtime analysis on the mix thread
|
|
if ( !VoiceLipSyncEnabled )
|
|
{
|
|
Voice.LipSync.Enabled = false;
|
|
ClearVoiceMorphs( sceneModel );
|
|
return;
|
|
}
|
|
|
|
var model = Model;
|
|
if ( model is null || model.MorphCount == 0 )
|
|
return;
|
|
|
|
Span<float> weights = stackalloc float[Visemes.Count];
|
|
var scale = 1.0f;
|
|
|
|
var soundFile = _voiceSoundFile;
|
|
var track = soundFile?.Visemes;
|
|
|
|
if ( track is not null )
|
|
{
|
|
// The sound has an authored viseme track, sample it at the playback position
|
|
track.Sample( Voice.Time, weights );
|
|
}
|
|
else if ( soundFile is null || soundFile.IsValidForPlayback )
|
|
{
|
|
// The sound is loaded and has no viseme track, analyze the audio as it plays
|
|
Voice.LipSync.Enabled = true;
|
|
|
|
var visemes = Voice.LipSync.VisemeWeights;
|
|
for ( var i = 0; i < weights.Length && i < visemes.Length; i++ )
|
|
{
|
|
// Gate out analysis noise, so silence doesn't twitch the face
|
|
var weight = visemes[i];
|
|
weights[i] = weight > VoiceRealtimeDeadzone ? weight : 0.0f;
|
|
}
|
|
|
|
scale = VoiceRealtimeScale;
|
|
}
|
|
else
|
|
{
|
|
// The sound is still loading, so we don't yet know if it has a track -
|
|
// and it isn't audible yet either. Hold off rather than kicking off
|
|
// realtime analysis we'd abandon.
|
|
return;
|
|
}
|
|
|
|
ApplyVoiceMorphs( sceneModel, weights, scale );
|
|
}
|
|
|
|
void ApplyVoiceMorphs( SceneModel sceneModel, ReadOnlySpan<float> visemeWeights, float scale )
|
|
{
|
|
_voiceMorphModel = Model;
|
|
|
|
sceneModel.Morphs.ApplyVisemes( visemeWeights, scale, VoiceMorphSmoothTime );
|
|
}
|
|
|
|
// Fade the morphs we've been driving back to their animated values
|
|
void ClearVoiceMorphs( SceneModel sceneModel )
|
|
{
|
|
if ( _voiceMorphModel is null )
|
|
return;
|
|
|
|
foreach ( var morphIndex in _voiceMorphModel.Morphs.VisemeMorphIndices )
|
|
{
|
|
sceneModel.Morphs.Reset( morphIndex, VoiceMorphSmoothTime );
|
|
}
|
|
|
|
_voiceMorphModel = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Let go of any voice morphs instantly. Called when the model is about to
|
|
/// change - our morph indices are in the old model's morph space, and the
|
|
/// scene object keeps its override slots across the swap, so anything we
|
|
/// leave behind (even a fade) would drive random morphs on the new model.
|
|
/// </summary>
|
|
internal void ReleaseVoiceMorphs()
|
|
{
|
|
if ( _voiceMorphModel is null || _voiceMorphModel == Model )
|
|
return;
|
|
|
|
var sceneModel = SceneModel;
|
|
if ( sceneModel.IsValid() )
|
|
{
|
|
foreach ( var morphIndex in _voiceMorphModel.Morphs.VisemeMorphIndices )
|
|
{
|
|
sceneModel.Morphs.Reset( morphIndex );
|
|
}
|
|
}
|
|
|
|
_voiceMorphModel = null;
|
|
}
|
|
}
|