Notifications
Article
【重现】死亡细胞中动画像素化在Unity里的实现
Published 11 days ago
87
0
【重现】死亡细胞中动画像素化在Unity里的实现
原文地址:https://zhuanlan.zhihu.com/p/39152103

目前我个人想到的比较好的3D模型转2D像素的渲染方法有:
1.通过后处理的方式将整个摄像机画面转为像素风格[1]。
2.将摄像机画面渲染到低分辨率的RenderTexture后再拉伸显示。
3.将3D模型预渲染成像素风格序列帧[2]。
当然,我标题都这么说了,肯定本文的主题就是第三种方法。前两种方法实际上是一个方向的变体,好处是不需要更改游戏素材设计,像素分布对齐,不方便的大概是对于素材的使用和掌控力度会小很多。
《死亡细胞》中美术师使用一款名为FBX Crouncher GUI的程序,将FBX的动画制作成序列帧后使用。经过一番搜索我并未发现这款软件被公开,所以只能手动重现。
我将3D模型转2D像素序列帧的功能罗列如下:
  1. 支持将静态的模型渲染到一张带透明通道的像素图片上。
  2. 支持像素图片的尺寸自定义。
  3. 支持预览——所见即所得。
  4. 支持将模型的一段动画输出到一组像素图片中。
  5. 支持动画进度的调节。
  6. 相对的界面支持*。
  7. 支持预览输出后的像素动画*。(划掉)

思路

使用射线检测方法,发出射线阵列获取碰撞点,然后根据碰撞点信息输出贴图。之所以要碰撞点信息而不是直接采样颜色,是因为我另外打算实现2D像素动画中的光照效果,需要法线信息。

准备工作

既然是带动画的模型,导入到Unity中之后多半离不开SkinnedMeshRenderer,因此不能直接使用MeshCollider,而是先要将每帧的Mesh烘焙出来后再使用。
编写MeshBaker.cs:
/// <summary> /// 用于统一MeshRenderer和SkinnedMeshRenderer的碰撞体设定用的 /// </summary> [RequireComponent(typeof(MeshCollider))] [RequireComponent(typeof(Renderer))] public class MeshBaker : MonoBehaviour { private MeshCollider mc; private Renderer renderer; private Mesh mesh; private bool isStatic; // Use this for initialization void Start () { mc = GetComponent<MeshCollider>(); renderer = GetComponent<Renderer>(); mesh = new Mesh(); isStatic = !(renderer.GetType().Equals(typeof(SkinnedMeshRenderer))); } // Update is called once per frame void Update () { if (isStatic == false && renderer != null) { ((SkinnedMeshRenderer)renderer).BakeMesh(mesh); mc.sharedMesh = mesh; } } }
随后编写BodyPart.cs,用于统一色彩的采样。因为像素风格的素材对纹理精细度要求不高,有些小部件用纯色替代更为恰当,所以采样模式分为纯色和纹理两种模式:
using System.Collections; using System.Collections.Generic; using UnityEngine; /// <summary> /// 用于统一纯色和使用纹理的颜色采样用的 /// </summary> [RequireComponent(typeof(Renderer))] public class BodyPart : MonoBehaviour { public Color Color; public Material Mat; public Texture2D Texture; public bool isPureColor; //是否纯色 private Renderer mr; private Material myMat; private void Awake() { myMat = Instantiate(Mat) as Material; mr = GetComponent<Renderer>(); mr.material = myMat; } private void Update() { mr.material.SetColor("_Color", Color); mr.material.mainTexture = Texture; } internal UnityEngine.Color GetColor(Vector2 texcoord) { if (isPureColor) return Color; else return ((Texture2D)(mr.material.mainTexture)).GetPixelBilinear(texcoord.x, texcoord.y); } }
然后新建一个Unlit Shader,命名为Model,并创建使用该Shader的Model材质。由于渲染的时候只需要颜色信息,所以无需做其他处理。
使用的动画模型来自于Asset Store的免费资源[3]。

射线检测

新建RaySample.cs,在这里进行采样操作。当然,下述代码仅仅是将射线检测结果直接转化成了图片,但实际上射线检测信息还有其他作用,当然因为我是边写代码边写这篇文章的,所以先不说了。
SampleInfo[] StartSample() { SampleInfo[] res = new SampleInfo[SampleWidth * SampleHeight]; for (int y = 0; y < SampleHeight; y++) for (int x = 0; x < SampleWidth; x++) { res[y * SampleWidth + x] = SampleColor(new Ray(transform.position + new Vector3((x - SampleWidth * 0.5f) * PixelWidth, y * PixelWidth, 0), Vector3.forward)); } return res; } SampleInfo SampleColor(Ray ray) { var hits = Physics.RaycastAll(ray, RayLength); if (hits.Length == 0) return null; RaycastHit firstHit = hits[0]; if (firstHit.collider.gameObject.layer != 8) return null; SampleInfo resInfo = new SampleInfo(); resInfo.Normal = firstHit.normal; resInfo.Position = firstHit.point; resInfo.Color = firstHit.collider.GetComponent<BodyPart>().GetColor(firstHit.textureCoord); return resInfo; } Color[] CreateFrame(SampleInfo[] infos) { Color[] colors = new Color[SampleWidth * SampleHeight]; for (int i = 0; i < colors.Length; i++) { if (infos[i] != null) colors[i] = infos[i].Color; else colors[i] = new Color(0, 0, 0, 0); } return colors; }
然后我们堆砌好场景:
并渲染:

实时预览

其实到这一步,fbx anim转pixel的基本功能也就差不多了。但是既然都说了是工作流程,当然功能不是最重要的,保证开发流程的便利才是本文的重点。
实时预览也很简单,用UGUI的RawImage即可,需要注意的是生成Texture2D的时候要更改其过滤模式,保证点阵的清楚。
Texture2D previewTex = CreatePreview(CreateFrame(StartSample())); if (PreviewImg != null && previewTex != null) { previewTex.filterMode = FilterMode.Point; PreviewImg.rectTransform.sizeDelta = new Vector2(SampleWidth, SampleHeight) * 5; PreviewImg.texture = previewTex; }

进度调节

原本打算用Animator的Record[4],但是发现它只能作用在默认动画状态上,随作罢。虽然ForceStateNormalizedTime显示已被弃用,但是还是挺好用的。
public void OnProgressBarChanged() { Animator animator = modelRoot.GetComponentInChildren<Animator>(); animator.speed = 0f; animator.ForceStateNormalizedTime(progress.value * ((AnimationClip)curState.state.motion).length); }

动画导出

既然能够自然调节进度,那么也就自然能够导出,使用一个协程在每帧渲染结束后导出当前帧的动画:
IEnumerator StartExport(int totalFrames) { Debug.Log("Start export!"); Animator animator = modelRoot.GetComponentInChildren<Animator>(); animator.speed = 0f; var length = ((AnimationClip)curState.state.motion).length; for(int i = 0; i < totalFrames; i++) { animator.ForceStateNormalizedTime(i / (float)totalFrames); raySample.ExportCurrent(roleInput.text + "_auto", animInput.text + "-" + (i+1).ToString()); yield return new WaitForEndOfFrame(); } Debug.Log("Export complete!"); }

色调替换

色调的替换也是一个非常有利于开发的功能。这里我约定了8种颜色,分别是RGB通道的0/1组合,一般来说8种颜色已经够用了。

多重采样

目前而言我们只对一个像素采样一次,但是这是不够充分的,甚至在极端情况下是偏颇的。因此根据蒙特卡洛法,我们将对像素进行多次采样,并予以混合。
可以看到,原本的黑色边缘被淡化,受到了更多来自其他像素的干扰。

2D动态光照

这一部分我的想法是,通过将法线信息储存在另外一张序列帧中,在游戏运行中通过计算法线和光线的点积,对明暗面分别进行RGB调整。
不过最后的效果不尽如人意,过渡比较突兀,调节光照的过程不够直观,而且说实话,光是为了动态光照而需要多上一倍的资源占用,显然不是那么的……“足够产品”。
Shader代码如下,是拿Unity自带的Sprite Shader Default改的:
// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt) Shader "Sprites/Pixel" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _NormalTex("Normal Texture", 2D) = "white" {} _LightDir ("Light Direction", Vector) = (0,0,1) _LightIntensity ("Intensity", Range(0, 1)) = 0.5 _Color ("Tint", Color) = (1,1,1,1) [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0 [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1) [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1) [PerRendererData] _AlphaTex ("External Alpha", 2D) = "white" {} [PerRendererData] _EnableExternalAlpha ("Enable External Alpha", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex SpriteVert #pragma fragment frag #pragma target 2.0 #pragma multi_compile_instancing #pragma multi_compile _ PIXELSNAP_ON #pragma multi_compile _ ETC1_EXTERNAL_ALPHA #include "UnitySprites.cginc" sampler2D _NormalTex; float3 _LightDir; float _LightIntensity; fixed4 frag(v2f IN) : SV_Target { fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color; fixed4 normal = tex2D(_NormalTex, IN.texcoord); fixed d = dot(normalize(normal.xyz - Domain Name For Sale | Undeveloped), normalize(_LightDir)); c.rgb *= (1 - _LightIntensity + step(d, 0) * 2 * _LightIntensity); c.rgb *= c.a; return c; } ENDCG } } }
效果如下:

结语

这篇文章并无技术难点,主要是还原了《死亡细胞》美术师的工作流程。
当然,这里要多嘴提一句,并不是所有3D模型都适合用这种方法制作出像素素材的,因为《死亡细胞》的模型在设计之初就是为了像素表现,突出强调了角色身上的形状,而没有精雕细琢。
我认为适于像素化的3D模型最好也是为此重新制作的,3D模型像素化的目的不是表现效果,而是开发速度。
Github地址:https://github.com/noobdawn/3DModelTo2DPixel-Unity
参考文献
[1]https://assetstore.unity.com/packages/vfx/shaders/fullscreen-camera-effects/pixelation-camera-65900
[2]http://www.gamelook.com.cn/2018/01/319293
[3]https://assetstore.unity.com/packages/3d/animations/warrior-pack-bundle-2-free-42454
[4]https://blog.csdn.net/yangxun983323204/article/details/51491756

N
Noobdawn
2
Comments