Unity
后处理shader—以图片作为基础元素去渲染视野中的内容
概:本篇主要内容是如何在Unity中实现用图片作为基础元素去对相机最后拍到的内容做后处理渲染。(讲也不是很明白,建议直接移步效果预览那部分看看效果就明白了)
前期学习参照:这个效果的实现原理很大一部分参照于知乎罗老师的字符后处理渲染那篇文章,这里贴个链接Unity3D后期Shader特效-马赛克13-文字图像(灰度转ID|图像块坐标偏移)
(比较草率的)最终效果预览
- 首先关于这个最终效果有几点需要强调下,第一,由于我只是临时兴起摸鱼写了这个东西,所以并没有做更加精细的优化处理,导致利用差不多30张图片就是极限了,如果将一张纹理的横向纵向限度都利用起来,约莫可以利用近千张图片。精细度直接幂次增长。第二,还是由于我比较懒且仅仅是摸鱼写的东西,所以我并没有对用的图片进行筛选,简单抓起来自己收藏文件夹里的“老婆”们,批量拖入就用了,导致很多图片色调相近,色阶并不是很丰富。第三,啊,这个不是我懒了,这个确实技术力有限,实现逻辑仅仅是用平均灰度来进行替换的,而并没有涉及到具体的颜色辨识问题,比方说一个很亮的红色,可能会用一个很亮的绿色去替代(因为灰度相近,直接按照最终效果相近就放一起了)。
- 好,接下来是真正的最终效果预览
- 首先是相机拍摄到的内容原图
- 然后是精度较高的后处理后的效果(不知道为啥截图放到csdn里效果有点小差别)
- 接着是精度下调的效果(为了能看明白这确实是用一张张图片拼出来的dio)
啊,如上就是最终实现的一个效果,如果有兴趣就继续往下看,我会简介如何实现这样的东西,如果感觉很拉跨就跑路(如果有好的建议不妨给一手评论欸嘿嘿)
总体思路
- 首先是关于我为啥会想到做这么个玩意,早些天的时候在学习罗老师的那个后处理效果,感觉很好玩,实现起来也费了点劲,根本闹不清这UV怎么算的(虽然闹清后感觉也就那样了)。然后前俩天在写(抄)作业的时候突然想到了(对,细节凭空想起来,我就是容易走神)以前看到过的别人发的那种用小图片拼接成一个大图片的图,以前好奇过这种东西怎么实现的,当时根本没学过图形方面内容,觉得应该就是一堆图乱拼起来,然后反手原图盖一层上去,就有这个效果了。然后现在想起来,草率了。人家可能还真是实打实的按照图片本有的颜色拼起来的效果。于是脑洞大开决定在Unity中做一个这样的后处理效果,实时处理每一帧的镜头(对GPU开销还是比较大的玩玩就好)。(想迫害自己做过的游戏项目了,如果用自己游戏的截图去渲染自己的游戏,想想就觉得刺激又鬼畜
- 然后梳理下要实现这个东西需要做些什么,首先后处理需要用后处理shader来做,这个shader中我们需要拿到这些图片如果一个一个去定义就无法实现足够的动态性,所以我们需要在外界将这些要用到的纹理用一个脚本拼接成一张传个这个shader,然后只需要告诉它我给你的这张纹理里包含了多少的子纹理。在shader中按照自己编写的逻辑去解开拼接使用就行了。
除了上面提到的这个c#脚本和shader以外,由于我是在之前专门做后处理学习的项目里做的,这个项目里我搭了个小架子,所以后面我会简述下这个架子的内容。
综上所述,我们要实现这个效果主要需要俩个核心东西:根据纹理去做最终后处理的shader,将要用的纹理打包成一张的c#脚本。
架子简介
- 这个架子大概从上到下依次是,相机脚本,对应不同渲染风格的c#脚本,c#脚本所调用的一系列shader。
- 相机脚本:主要负责在属性检查器中公开当前选择的滤镜类型,所以我给其定义了一个枚举类型来作为滤镜类型。并且相机脚本中得有各种渲染风格的具体对象,以便在属性检查器中更换了渲染模式后能及时的切换其使用的渲染脚本。(代码如下)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class Pixlate : MonoBehaviour
{
public enum EDirType
{
彩铅风格,
像素风格,
字符风格,
图片填充
}
RenderTexture re1;
RenderTexture re2;
public EDirType 滤镜类型 = EDirType.彩铅风格;
public Filter 滤镜;
static private List<Filter> col;
public Material[] effectMaterial;
private void Start()
{
col = new List<Filter>();
col.Add(new Color_Pencil_Filter());
col.Add(new PixelFilter());
col.Add(new Char_Filter());
col.Add(new Picture_Filter());
}
private void Update()
{
switch(滤镜类型)
{
case EDirType.彩铅风格:
if (滤镜 == col[0]) break;
滤镜 = col[0];
break;
case EDirType.像素风格:
if (滤镜 == col[1]) break;
滤镜 = col[1];
break;
case EDirType.字符风格:
if (滤镜 == col[2]) break;
滤镜 = col[2];
break;
case EDirType.图片填充:
if (滤镜 == col[3]) break;
滤镜 = col[3];
break;
default:
break;
}
}
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if(滤镜 == null)
{
return;
}
effectMaterial = 滤镜.material;
if(re1 == null)
{
re1 = new RenderTexture(source);
}
if (re2 == null)
{
re2 = new RenderTexture(source);
}
Graphics.Blit(source, re1);
foreach (Material m in effectMaterial)
{
Graphics.Blit(re1, re2, m);
Graphics.Blit(re2, re1);
}
//滤镜.Random_Parameter();
Graphics.Blit(re1, destination);
}
}
- 不同渲染风格的c#脚本:这些脚本需要负责将自身对应的渲染shader载入并实例化成材质数组,传给相机脚本,让其做后处理渲染。所以我为它们定义了一个抽象父类,规定了这一类脚本都需要有的一些元素:当前滤镜的名字,shader对应的路径,载入好的shader对象,实例化好的材质数组,初始化方法,材质所需参数随机载入方法(这个方法与本篇所涉及的后处理效果没很大关系)。(父类代码如下)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public abstract class Filter
{
public string[] shaderFile {
set; get; } //shader路径
public Shader[] shader {
set; get; } //shader
public string filterName {
set; get; } //滤镜名字
public Material[] material {
set; get; } //材质球
public void Init()
{
InitFile();
shader = new Shader[shaderFile.Length];
material = new Material[shaderFile.Length];
for (int i = 0;i<shaderFile.Length;i++)
{
shader[i] = Shader.Find(shaderFile[i]);
material[i] = new Material(shader[i]);
}
Random_Parameter();
}
public abstract void InitFile();
public abstract void Random_Parameter();
}
- c#脚本所调用的一系列shader:为什么说是调用的一系列shader,因为不一定一个效果只需要后处理一次,可能要经过多次不同效果的处理才能达到最终所需要的效果,所以其对应的后处理shader应该是个数组,而不是单个shader。
纹理拼接的c#脚本
- 首先需要取得我们要用的一堆纹理,我这边用的 Resources.Load 这个方法去加载我放在Resources文件夹下的纹理资源。(由于人懒没做什么文件读取方面的自动检测纹理方法,我直接暴力把名字改成一样的格式,然后一手循环全部读入了)
- 纹理读取到以后还不能直接去操作这些纹理,在unity中Texture2D对象是有保护机制的,需要解开这个保护机制才能对其进行操作,我用的是早些时候在百度上嫖的一个方法代码(啊,忘记作者是谁了,贴不得链接,致歉),如下方法,将一个2d纹理传入,会返回一个没有保护机制的2d纹理
private Texture2D duplicateTexture(Texture2D source)
{
//2d纹理解除保护
RenderTexture renderTex = RenderTexture.GetTemporary(
source.width,
source.height,
0,
RenderTextureFormat.Default,
RenderTextureReadWrite.Linear);
Graphics.Blit(source, renderTex);
RenderTexture previous = RenderTexture.active;
RenderTexture.active = renderTex;
Texture2D readableText = new Texture2D(source.width, source.height);
readableText.ReadPixels(new Rect(0, 0, renderTex.width, renderTex.height), 0, 0);
readableText.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(renderTex);
return readableText;
}//2d纹理解除保护
- 既然解开纹理保护极致了,就可以上手去操作了,既然是要用这些图片去作为基础元素显示一张图的,那么就需要将这些图片的某些特定的部分和最终要显示的部分的颜色对应起来,这样才能保证一样的颜色用到一样的图片来显示。这边我选择使用灰度,所以首先要计算纹理数组中每一个纹理的灰度,并将其存储到一个数组中,与纹理数组元素一一对应起来,然后反手一个冒泡排序,以灰度数组为依据,对这俩个数组进行排序,就可以保证这些纹理按照灰度从低到高的顺序排起来了。(灰度的计算公式是让颜色的rgb元素分别与0.299,0.587,0.114相乘取和)(代码如下)
for (int i = 0; i < TexList.Count; i++)
{
//计算灰度并对应存储
double gray = 0;
for(int j = 0;j<TexList[i].width;j++)
{
for (int k = 0; k < TexList[i].height; k++)
{
gray += TexList[i].GetPixel(j, k).r * 0.299;
gray += TexList[i].GetPixel(j, k).g * 0.587;
gray += TexList[i].GetPixel(j, k).b * 0.114;
}
}
gray /= (TexList[i].width * TexList[i].height);
GrayList.Add(gray);
}//计算灰度并对应存储
double d = 0;
Texture2D t;
for (int i = 0;i< TexList.Count;i++)
{
for(int j = 0;j< TexList.Count-i-1;j++)
{
if(GrayList[j]> GrayList[j+1])
{
d = GrayList[j];
GrayList[j] = GrayList[j + 1];
GrayList[j + 1] = d;
t = TexList[j];
TexList[j] = TexList[j + 1];
TexList[j + 1] = t;
}
}
}//按照灰度排序,亮的在后
- 按照灰度排好序就可以将这些纹理拼接成最终要用的大纹理了,我们要知道,将这些纹理拼接起来最后要在shader中使用的话,简单粗暴的拼起来最终导致各种图片边长不同,会及大幅度增加最终使用的难度。所以在这里我决定取遍历这个纹理数组,取到最短的一条边作为基准,将所有图片按照这个最短边缩放为一个正方形的纹理,然后再拼接到一起。这边我实现了一个方法,是将其中一个纹理整合到最终纹理对应的位置,由于这个项目比较摸鱼所以我只是简单实现了横向的拼接,没有做纵向的拓展,导致最终做出来只是一长条纹理,能存储的纹理数量很有限。拼接原理也可以参照下图(画了个草图,有点辣鸡)
方法实现代码如下:
private void Texture_Clone(Texture2D texture, Texture2D tex,int minwidth,int num)
{
//参数1:最终克隆到的texture中
//参数2:要缩放并克隆的目标
//参数3:缩放的边长(正方)
//参数4:第几个克隆的对象,对应位置
float xb = tex.width / minwidth;
float yb = tex.height / minwidth;
for (int i = 0;i<minwidth;i++)
{
for(int j = 0;j<minwidth;j++)
{
texture.SetPixel(i+ num * minwidth, j, tex.GetPixel((int)( i * xb), (int)(j * yb)));
}
}
texture.Apply();
}//将指定的texture2d内容按照比例缩放克隆到texture指定位置
- 纹理也拼接好了,接下里就是将纹理以及相应参数传入对应shader中,这边我在“材质所需参数随机载入方法”(之前介绍架子的时候提到过)这个方法中去传入这些参数,这个方法默认在载入时会调用一次,所以就可以达到参数传递一次的效果了。
public override void Random_Parameter()
{
//随机传参方法
//传入一个纹理
material[0].SetTexture("_PictureTex", texture);
//传入一个字符密度参数
material[0].SetFloat("_TileSize", _TileSize);
//传入图片数量参数
material[0].SetFloat("_PictureCount", TexList.Count);
}
- 完整的c#代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Picture_Filter : Filter
{
//渲染精细度,数字越小越精细
public float _TileSize = 2;
//Texture2D _tex = (Texture2D)Resources.Load("Lighthouse");
//要用的纹理数组
private List<Texture2D> TexList = new List<Texture2D>();
//对应纹理数组的灰度数组
private List<double> GrayList = new List<double>();
//最终拼接好的纹理
private Texture2D texture;
//所有纹理中的纹理最短边
private int minTex = 1000000;
public Picture_Filter()
{
filterName = "图片填充";
Init();
}//构造方法
private Texture2D duplicateTexture(Texture2D source)
{
//2d纹理解除保护
RenderTexture renderTex = RenderTexture.GetTemporary(
source.width,
source.height,
0,
RenderTextureFormat.Default,
RenderTextureReadWrite.Linear);
Graphics.Blit(source, renderTex);
RenderTexture previous = RenderTexture.active;
RenderTexture.active = renderTex;
Texture2D readableText = new Texture2D(source.width, source.height);
readableText.ReadPixels(new Rect(0, 0, renderTex.width, renderTex.height), 0, 0);
readableText.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(renderTex);
return readableText;
}//2d纹理解除保护
private void InitTexList()
{
for(int i = 1;i<=30;i++)
{
TexList.Add(Resources.Load<Texture2D>("Texture/Picture_Filter/123 (" + i+")"));
}
for (int i = 0; i < TexList.Count; i++)
{
//解开纹理保护
TexList[i] = duplicateTexture(TexList[i]);
}//解开纹理保护
for (int i = 0; i < TexList.Count; i++)
{
//计算灰度并对应存储
double gray = 0;
for(int j = 0;j<TexList[i].width;j++)
{
for (int k = 0; k < TexList[i].height; k++)
{
gray += TexList[i].GetPixel(j, k).r * 0.299;
gray += TexList[i].GetPixel(j, k).g * 0.587;
gray += TexList[i].GetPixel(j, k).b * 0.114;
}
}
gray /= (TexList[i].width * TexList[i].height);
GrayList.Add(gray);
}//计算灰度并对应存储
double d = 0;
Texture2D t;
for (int i = 0;i< TexList.Count;i++)
{
for(int j = 0;j< TexList.Count-i-1;j++)
{
if(GrayList[j]> GrayList[j+1])
{
d = GrayList[j];
GrayList[j] = GrayList[j + 1];
GrayList[j + 1] = d;
t = TexList[j];
TexList[j] = TexList[j + 1];
TexList[j + 1] = t;
}
}
}//按照灰度排序,亮的在后
}//初始化纹理数组
private void Texture_Clone(Texture2D texture, Texture2D tex,int minwidth,int num)
{
//参数1:最终克隆到的texture中
//参数2:要缩放并克隆的目标
//参数3:缩放的边长(正方)
//参数4:第几个克隆的对象,对应位置
float xb = tex.width / minwidth;
float yb = tex.height / minwidth;
for (int i = 0;i<minwidth;i++)
{
for(int j = 0;j<minwidth;j++)
{
texture.SetPixel(i+ num * minwidth, j, tex.GetPixel((int)( i * xb), (int)(j * yb)));
}
}
texture.Apply();
}//将指定的texture2d内容按照比例缩放克隆到texture指定位置
public override void InitFile()
{
InitTexList();//初始化纹理数组
for(int i = 0;i<TexList.Count;i++)
{
if (minTex > TexList[i].height)
{
minTex = TexList[i].height;
}
if (minTex > TexList[i].width)
{
minTex = TexList[i].width;
}
}//取最短边
texture = new Texture2D(minTex * TexList.Count, minTex);//实例化最终要用的纹理
for (int i = 0; i < TexList.Count; i++)
{
Texture_Clone(texture, TexList[i], minTex, i);
}//为最终要用的纹理填入参数
//为渲染器初始化
shaderFile = new string[1];
shaderFile[0] = "Custom/Myshader/Picture_Shader";
}//初始化方法
public override void Random_Parameter()
{
//随机传参方法
//传入一个纹理
material[0].SetTexture("_PictureTex", texture);
//传入一个字符密度参数
material[0].SetFloat("_TileSize", _TileSize);
//传入图片数量参数
material[0].SetFloat("_PictureCount", TexList.Count);
}
}
利用拼接的纹理做后处理渲染的shader
- 首先是这个shader需要的参数部分,一共需要四个参数,如下分别是主纹理(这个由相机脚本传入当前拍摄到的画面),我们所拼接出的渲染纹理,渲染精细程度(这个涉及到像素化部分像素块的大小,越小自然越精细),拼接出的纹理中包含多少张纹理。
Properties
{
_MainTex ("Texture", 2D) = "white" {
}
_PictureTex ("PictureTex", 2D) = "white" {
}
_TileSize("TileSize", Range(0,100)) = 1
_PictureCount("PictureCount",Range(0,100)) = 1
}
- 关于顶点函数部分,这部分基本不需要做什么操作,就不多说了
- 关于片元函数部分,这部分是我们的主力,需要在这部分中对当前需要渲染点的uv进行像素区块划分,然后在主纹理中取到其对应的颜色,在对其灰度进行计算,得出其应该使用第几个图片,然后到拼接出的渲染纹理中去取对应图片中的对应点。比较难的部分是这个要去取最终颜色的uv怎么计算,关于这个东西强烈建议看看罗老师的文章(开头我提到的那篇),图解很详细,我讲不太明白,但我这篇用到的最终uv计算公式也不过是罗老师那篇魔改来的,原理一样。
- 最终shader代码如下
Shader "Custom/Myshader/Picture_Shader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {
}
_PictureTex ("PictureTex", 2D) = "white" {
}
_TileSize("TileSize", Range(0,100)) = 1
_PictureCount("PictureCount",Range(0,100)) = 1
}
SubShader
{
Cull Off ZWrite Off ZTest Always
pass
{
CGPROGRAM
#pragma vertex _Vert
#pragma fragment Pixel
#include "UnityCG.cginc"
struct VertexInput
{
float4 Pos:POSITION;
float2 uv:TEXCOORD0;
};
struct VertexOutput
{
float4 Pos:SV_POSITION;
float2 uv:TEXCOORD0;
};
//参数定义
sampler2D _MainTex;
sampler2D _PictureTex;
float _TileSize;
int _PictureCount;
VertexOutput _Vert(VertexInput v)
{
VertexOutput r;
//将顶点转换到剪裁空间
r.Pos = UnityObjectToClipPos(v.Pos);
r.uv = v.uv;
return r;
}
fixed4 Pixel(VertexOutput v):SV_Target
{
//用目标像素尺寸和屏幕默认尺寸计算出参数TileSum
float2 TileSum = _ScreenParams / _TileSize;
//用参数TileSum对uv进行区块划分
float2 uv_Mosaic = ceil(v.uv *TileSum)/ TileSum;
//对纹理进行取样显现
fixed4 col = tex2D(_MainTex, uv_Mosaic);
//计算当前点的灰度
fixed gray = saturate(dot(col.xyz,fixed3(0.299, 0.587, 0.114)));
int num = ceil(gray *_PictureCount); //当前点应该是用第几个图片对应的纹理
float2 uv_picture =(v.uv *TileSum-ceil(v.uv *TileSum));
uv_picture.x /= _PictureCount;
uv_picture.x -= (float)num/_PictureCount;
fixed4 r = tex2D(_PictureTex, uv_picture);
return r;
}
ENDCG
}
}
FallBack "Diffuse"
}
结语
- 有关技术实现部分就如上所示,这边想提一嘴这个实现中存在的一些毛病,可以优化的地方,比方说我是直接一长条拼接的纹理,所以当图片长度和数量一上去,立马就会报错,显示我要拼接的纹理长度超限,最终我平均只能利用30张图片,如果能把纵向的空间也利用起来,其实就是30*30共900张纹理拼接,精细度能提升不少。另外用灰度作为颜色绑定的依照来说,总还是不准确,比如我展示的效果图中黄色的善矣最终替代的是黑色的部分,而黄色部分的dio却由一些粉色的图来替代,效果真的是一言难尽。
- 最后感谢阅读(第一次写这么又臭又长的博客)