这周会计划研究LipSync方向,会写一些资料记录过程。本篇并不讲详细使用步骤,只讨论其功能实现。

本篇评测的插件列表如下:
** SALSA With RandomEyes **
** UniLip **

评测的方向: 1. 嘴型同步
评测角度: 1. 实现方式 2. 可用性

SALSA With RandomEyes

插件包含 SALSA与RandomEyes 两部分。这里与后文并不关注RandomEyes相关话题。

实现方式

美术:使用 blend shape 制作 三口型

在分析了插件包内的 boxHead.fbx 文件之后看到了如下层级结构:

boxHead.fbx

具体做法在层级面板上已经可以反推出来美术的制作流程
boxHead.fbx

  1. 美术这边首先在不同的层级上作出不同的口型表现,本插件要求美术在上图中三个Say开头的层级上实现三个口型
  2. 使用 blend shape 绑定。这是一种做融合动画(面部口型)的特殊做法。为什么不用骨骼动画?因为用骨骼动画做节点太多了。

策划配置:三变量

策划只需要配置三个阀值给程序作为控制动画过渡的条件

程序实现:控制面部口型过渡

为了达到控制口型的目的,SALSA启动了一个携程在每个audioUpdateDelay周期内对音频采样。后获得 average

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private IEnumerator UpdateSample()
{
while (true)
{
float addedVals = 0.0f;
this.sample = new float[this.sampleSize];
if ((bool) ((Object) this.audioSrc))
this.audioSrc.GetSpectrumData(this.sample, 0, FFTWindow.BlackmanHarris);
for (int index = 0; index < this.sample.Length; ++index)
addedVals += this.sample[index];
if (!this.audioSrc.isPlaying && (double) this.average == 0.0)
this.writeAverage = false;
if (this.audioSrc.isPlaying)
this.writeAverage = true;
if (this.writeAverage)
{
this.average = 0.0f;
this.average = addedVals / (float) this.sampleSize;
}
yield return (object) new WaitForSeconds(this.audioUpdateDelay);
}
}

通过 average 配合策划设置的三个数指(saySmallTrigger/sayMediumTrigger/sayLargeTrigger) 来控制什么时间点融合什么动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private void Update()
{
if (this.prevIsTalking != this.isTalking)
{
this.prevIsTalking = this.isTalking;
if (this.broadcast)
this.TalkStatusChanged();
}
if ((double) this.average < (double) this.saySmallTrigger)
{
this.say = "Rest";
this.sayIndex = 0;
this.saySmallValue = SalsaUtility.LerpRangeOfMotion(this.saySmallValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayMediumValue = SalsaUtility.LerpRangeOfMotion(this.sayMediumValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayLargeValue = SalsaUtility.LerpRangeOfMotion(this.sayLargeValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
}
else if ((double) this.average < (double) this.sayMediumTrigger)
{
this.say = "Small";
this.sayIndex = 1;
this.saySmallValue = SalsaUtility.LerpRangeOfMotion(this.saySmallValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Increment);
this.sayMediumValue = SalsaUtility.LerpRangeOfMotion(this.sayMediumValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayLargeValue = SalsaUtility.LerpRangeOfMotion(this.sayLargeValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
}
else if ((double) this.average < (double) this.sayLargeTrigger)
{
this.say = "Medium";
this.sayIndex = 2;
this.saySmallValue = SalsaUtility.LerpRangeOfMotion(this.saySmallValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayMediumValue = SalsaUtility.LerpRangeOfMotion(this.sayMediumValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Increment);
this.sayLargeValue = SalsaUtility.LerpRangeOfMotion(this.sayLargeValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
}
else
{
this.say = "Large";
this.sayIndex = 3;
this.saySmallValue = SalsaUtility.LerpRangeOfMotion(this.saySmallValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayMediumValue = SalsaUtility.LerpRangeOfMotion(this.sayMediumValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Decrement);
this.sayLargeValue = SalsaUtility.LerpRangeOfMotion(this.sayLargeValue, this.blendSpeed, this.rangeOfMotion, SalsaUtility.BlendDirection.Increment);
}
this.sayAmount.saySmall = this.saySmallValue;
this.sayAmount.sayMedium = this.sayMediumValue;
this.sayAmount.sayLarge = this.sayLargeValue;
if ((bool) ((Object) this.skinnedMeshRenderer))
{
this.skinnedMeshRenderer.SetBlendShapeWeight(this.saySmallIndex, this.sayAmount.saySmall);
this.skinnedMeshRenderer.SetBlendShapeWeight(this.sayMediumIndex, this.sayAmount.sayMedium);
this.skinnedMeshRenderer.SetBlendShapeWeight(this.sayLargeIndex, this.sayAmount.sayLarge);
}
if (!(bool) ((Object) this.audioSrc))
return;
if (this.audioSrc.isPlaying)
this.isTalking = true;
else
this.isTalking = false;
}

UniLip

此插件可实现与 SALSA With RandomEyes 的实现原理一样,侧重点不同,他开放了更多的口型给策划配置。

此函数等于 SALSA With RandomEyes 在上文例举的 UpdateSample 代码片段。不同的是作者将这段代码放在了Update循环之中,从纯代码上看效率比SALSA With RandomEyes还要差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void AudioProcess()
{
if (audioSource == null)
return;
audioSource.GetOutputData(samples, 0);
float sum = 0;
for (int i = 0; i < SAMPLECOUNT; i++)
{
sum += Mathf.Pow(samples[i], 2);
}

rmsValue = Mathf.Sqrt(sum / SAMPLECOUNT);
dbValue = 20 * Mathf.Log10(rmsValue / REFVALUE);

audioSource.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
float maxV = 0;
int maxN = 0;
for (int i = 0; i < SAMPLECOUNT; i++)
{
if (spectrum[i] > maxV && spectrum[i] > THRESHOLD)
{
maxV = spectrum[i];
maxN = i;
}
}
float sum2 = 0;
for (int i = 0; i < SAMPLECOUNT; i++)
{
sum += Mathf.Abs(samples[i]);

}
volume = (sum / SAMPLECOUNT);

int picthCounter = 0;
float lastDir = 0;
float lastPos = 0;
for (int i = 0; i < SAMPLECOUNT; i++)
{
float dir = samples[i];
if (Mathf.Sign(dir) != Mathf.Sign(lastDir))
picthCounter++;

lastDir = dir;
lastPos = samples[i];

}
pitchValue2 = (picthCounter * 1000.0f / SAMPLECOUNT);

float freqN = maxN;

if (maxN > 0 && maxN < SAMPLECOUNT - 1)
{
float dL = spectrum[maxN - 1] / spectrum[maxN];
float dR = spectrum[maxN + 1] / spectrum[maxN];
freqN += 0.5f * (dR * dR - dL * dL);
}
pitchValue = freqN * 24000 / SAMPLECOUNT;
}

与 SALSA With RandomEyes 不同的地方是他支持了多个口型的随机。

可用性

可借鉴优点

  1. 策划门槛低,使用美术制作的 blend shape 减小导出模型体积,并且灵活程序控制,程序只需要通过一些策划配置好的条件便可以将任意两个口型进行过渡。
  2. 这种做法不存在动画衔接问题。因为所有的状态我们都可以看作是两个clip之间的过渡。
  3. 通过采样率与音频的高低来控制脸型与嘴形的同步,在某种意义上来说是一种假同步,正是这种”假同步”才不存在多语言问题。
  4. 免预处理,导入即用

缺点

  1. 表现上不足,假同步解决了多语言问题但是带来了表现力不足,仔细看口型与音频完全不是一回事,但通常我们不会仔细看。
  2. 移动端效率堪忧,在移动端上大规模使用本做法 周期采样音频 是有效率问题的。
  3. 不能直接使用,如果使用此中做法开发口型系统这两个插件都不能直接用,需要基于项目对插件进行二次开发。

🔗 捕捉音谱
🔗 用Unity3D内部频谱分析方法做音乐视觉特效的原理说明
🔗 How to animate a character with blend shapes