点击任意处关闭

在 Unity 中还原浊心斯卡蒂的动态立绘【前】


NOTICE
正在播放本页的背景音乐~ 点击导航栏上的音符图标看看吧~

前言

浊心斯卡蒂是明日方舟二周年时出的六星异格干员。她实在太美啦,而且还有动态立绘!!!作为蒂厨的我,当然要行动起来啦~(坏笑)

先来看看最终效果吧!

实录画面(战损版)

为斯卡蒂献上心脏!!!

好的,回到正事。接下来我将会更新一系列文章来详细讲解它的还原方法,主要会涵盖以下内容:

  • AssetStudio & uTinyRipper 解包资源
  • Spine 插件导入以及错误修复
  • Shader 还原
  • C# 脚本编写

这篇文章,作为该系列的第零篇,主要用来为后面的内容做一些铺垫。

::: note-warning
下文内容将会被移动至其他文章。
:::


::: note-info
Unity 版本:2020.3.2f1c1。
:::

导入 Spine 插件

明日方舟使用的是 Spine 3.5.51。其 Unity 版本的插件可以在此处下载。下载完成后导入至 Unity 项目中。

由于这个 Spine 的版本比较老,所以导入较新版本的 Unity 后会出现一系列编译错误。

修复编译错误

Error Messages
error CS0165: Use of unassigned local variable 'color'

在对应文件中找到下面的方法并作出相应修改:

public static void FillVerts (Skeleton skeleton, int startSlot, int endSlot, float zSpacing, bool pmaColors, Vector3[] verts, Vector2[] uvs, Color32[] colors, ref int vertexIndex, ref float[] tempVertBuffer, ref Vector3 boundsMin, ref Vector3 boundsMax, bool renderMeshes = true) {
-   Color32 color;
+   Color32 color = default;
    var skeletonDrawOrderItems = skeleton.DrawOrder.Items;
    float a = skeleton.a * 255, r = skeleton.r, g = skeleton.g, b = skeleton.b;

    // 下面内容省略......
}

Error Messages
error CS0122: 'Texture.Texture()' is inaccessible due to its protection level

在对应文件中找到下面的字段并作出相应修改:

-   Texture m_previewTex = new Texture();
+   Texture m_previewTex = null;

public override void OnInteractivePreviewGUI (Rect r, GUIStyle background) {
    this.InitPreview();

    // 下面内容省略......
}

至此,问题就都解决了(至少我遇到的问题是都解决了¯\(ツ)/¯)。

修改部分功能

这个版本的 Spine 会在切换动画时先自动切换到 SetupPose,从而导致动画间过渡变得很鬼畜不自然。为了解决这个问题,我们需要在 /Spine/spine-csharp/AnimationState.cs 中做一些修改,如下所示:

/// <summary>From last to first mixingFrom entries, sets timelinesFirst to true on last, calls checkTimelineUsage on rest.</summary>
private void SetTimelinesFirst (TrackEntry entry) {
    if (entry.mixingFrom != null) {
        SetTimelinesFirst(entry.mixingFrom);
        CheckTimelinesUsage(entry, entry.timelinesFirst);
        return;
    }
    var propertyIDs = this.propertyIDs;
    var timelines = entry.animation.timelines;
    int n = timelines.Count;
    entry.timelinesFirst.EnsureCapacity(n); // entry.timelinesFirst.setSize(n);
    var usage = entry.timelinesFirst.Items;
    var timelinesItems = timelines.Items;
    for (int i = 0; i < n; i++) {
        propertyIDs.Add(timelinesItems[i].PropertyId);
-       usage[i] = true;
+       usage[i] = false;
    }
}

提取资源文件

明日方舟的资源文件的常用获取方式有两种:

  1. PRTS 网站上获取(F12 后找源文件下载)。
  2. 解包游戏的源文件。

由于第一种比较简单,这里我只介绍第二种方式。

Spine 文件

找到 /arts/dynchars/char_1012_skadi2/dyn_illust_char_1012_skadi2_2.ab 这个 AssetBundle 文件。它可能直接被包含在游戏包体内,也可能在 Application.persistentDataPath 下(这取决于你的游戏版本)。找到以后用 AssetStudio 打开。

需要提取的资源如下:

名称类型
dyn_illust_char_1012_skadi2.atlasTextAsset
dyn_illust_char_1012_skadi2.skelTextAsset
dyn_illust_char_1012_skadi2Texture2D
dyn_illust_char_1012_skadi2[alpha]Texture2D
dyn_illust_char_1012_skadi22Texture2D
dyn_illust_char_1012_skadi22[alpha]Texture2D

音频文件

同样的方法找到 /audio/sound_beta_2/voice/char_1012_skadi2.ab 文件。提取其中的全部音频(共35个)。

Shader 编写

TODO

ShaderLab
Shader "Unlit/AlphaSplit" { Properties { [NoScaleOffset] _MainTex ("Texture(Main)", 2D) = "white" {} [NoScaleOffset] _AlphaTex ("Texture(Alpha)", 2D) = "white" {} _Color("Color", Color) = (1, 1, 1, 1) } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" } LOD 100 Fog { Mode Off } Cull Off ZWrite Off Blend One OneMinusSrcAlpha Lighting Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : TEXCOORD1; }; sampler2D _MainTex; sampler2D _AlphaTex; fixed4 _Color; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 color = tex2D(_MainTex, i.uv); color.a = tex2D(_AlphaTex, i.uv).r; return color * _Color * i.color; } ENDCG } } }

控制代码

TODO

C#
using System; using System.Collections.Generic; using Spine; using Spine.Unity; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using Random = UnityEngine.Random; [RequireComponent(typeof(RawImage))] public class DynIllustChar : MonoBehaviour, IPointerClickHandler { [Serializable] public struct VoiceData { public AudioClip Audio; [TextArea(5, 8)] public string Text; } [Header("Configs")] [SerializeField] private Vector2Int m_ScreenResolution = new Vector2Int(1920, 1080); [Header("References")] [SerializeField] private SkeletonAnimation m_Animation; [SerializeField] private Camera m_CharRenderCamera; [SerializeField] private AudioSource m_VoiceAudioSource; [SerializeField] private Text m_VoiceText; [Header("Animations")] [SpineAnimation(dataField = "m_Animation")] public string IdleAnimation = "Idle"; [SpineAnimation(dataField = "m_Animation")] public string InteractAnimation = "Interact"; [SpineAnimation(dataField = "m_Animation")] public string SpecialAnimation = "Special"; [Header("Voices")] public VoiceData StartVoice; public VoiceData IdleVoice; public List<VoiceData> InteractVoices; private RenderTexture m_CharRT; private bool m_IsPlayingInteract; private float m_LastTimePlaySpecial; private float m_LastTimeInteract; private void Start() { m_CharRT = RenderTexture.GetTemporary(m_ScreenResolution.x, m_ScreenResolution.y, 0, RenderTextureFormat.BGRA32, RenderTextureReadWrite.sRGB); m_CharRenderCamera.targetTexture = m_CharRT; GetComponent<RawImage>().texture = m_CharRT; m_IsPlayingInteract = false; m_LastTimePlaySpecial = Time.time; m_LastTimeInteract = Time.time; m_Animation.AnimationState.End += (TrackEntry trackEntry) => { if (trackEntry.Animation.Name == InteractAnimation) { m_IsPlayingInteract = false; } }; m_Animation.AnimationState.Complete += (TrackEntry trackEntry) => { float time = Time.time; if (time - m_LastTimePlaySpecial >= 8 && Random.value < 0.3f) { m_LastTimePlaySpecial = time; PlayAnimation(SpecialAnimation); } }; PlayVoice(StartVoice); } private void OnDestroy() { RenderTexture.ReleaseTemporary(m_CharRT); } private void Update() { float time = Time.time; if (time - m_LastTimeInteract >= 5 * 60) { m_LastTimeInteract = time; PlayVoice(IdleVoice); } } void IPointerClickHandler.OnPointerClick(PointerEventData eventData) { if (m_IsPlayingInteract) { return; } m_IsPlayingInteract = true; m_LastTimeInteract = Time.time; int index = Random.Range(0, InteractVoices.Count); VoiceData voice = InteractVoices[index]; PlayVoice(voice); PlayAnimation(InteractAnimation); } private void PlayVoice(VoiceData voice) { if (m_VoiceAudioSource.isPlaying) { m_VoiceAudioSource.Stop(); } m_VoiceAudioSource.clip = voice.Audio; m_VoiceAudioSource.Play(); m_VoiceText.text = voice.Text; } private void PlayAnimation(string animationName) { m_Animation.AnimationState.SetAnimation(0, animationName, false); m_Animation.AnimationState.AddAnimation(0, IdleAnimation, true, 0); Debug.Log("Play " + animationName); } }

...


Title
Subtitle
00:00 / 00:00
播放列表