Unity Projector 投影器原理以及优化

很久很久以前,做过一个离线Mesh切割方式的Decay效果Unity3D中的贴花效果 适合场景景观布置,批次合并等,但运行时性能较差,这次我们来玩玩运行时投影器。


先上成平图


测试效果图, 图中的裤子上投影了一个眼睛
那么投影的原理是什么呢。。。 那么请看下面这张

这张图左下角就是投影器看到的景象,投影贴图“眼睛” 充满了整个投影器的视野,那么原理就呼之而出了。
在正常渲染裤子的顶点时,顺便变换到投影器的屏幕空间,然后再渲染裤子的片段处理函数中将位于投影器屏幕空间的像素都换成眼睛即可。

渲染裤子的Shader
Shader "Unlit/ProjectorShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
float4 projectorUV : TEXCOORD1;
float4 vertex : SV_POSITION;
};

float4x4 _ProjectorP;
float4x4 _ProjectorV;
float4x4 _ProjectorVP;
sampler2D _ProjectorTex;
sampler2D _ProjectorFallOut;
sampler2D _MainTex;
float4 _MainTex_ST;

v2f vert (appdata v)
{
v2f o;

o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);

float4x4 propMVP = mul(_ProjectorVP, unity_ObjectToWorld);
float4 projProjPos = mul(propMVP, v.vertex);

projProjPos = ComputeScreenPos(projProjPos);
o.projectorUV = projProjPos;

return o;
}

fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);

fixed4 projectorCol = tex2Dproj(_ProjectorTex, i.projectorUV);

// tex2Dproj = xyz/ w
fixed4 projectorFallOutCol = tex2Dproj(_ProjectorFallOut, i.projectorUV);

projectorCol *= projectorFallOutCol;

col.rgb += projectorCol.rgb;

return col;
}
ENDCG
}
}
}

自定义投影器的CS脚本

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class CustomProjector : MonoBehaviour {
public Camera ProjectorCam;
public Texture2D Tex;
public Texture2D FallOut;
public Material Mat;

private void Start()
{

}

private void Update()
{
Matrix4x4 P = GL.GetGPUProjectionMatrix(ProjectorCam.projectionMatrix, false);
Matrix4x4 V = ProjectorCam.worldToCameraMatrix;
Mat.SetMatrix("_ProjectorV", V);
Mat.SetMatrix("_ProjectorP", P);
Mat.SetMatrix("_ProjectorVP", P * V);
Mat.SetTexture("_ProjectorTex", Tex);
Mat.SetTexture("_ProjectorFallOut", FallOut);
}
}

范例中的代码借用了Unity的相机,实际上并不需要相机,仅仅是借用了相机的投影矩阵和世界空间矩阵而已。

很多项目组在制作移动端游戏时,都使用Projector来制作主角的投影,虽然比起ShadowMap是优化了许多,但是实际上只要和Projector碰撞到物件其DC 都会翻倍, 对于我来说,这还是不可接受的。
而使用上面范例的代码,可以让DC不翻倍,但是并不通用, 因为受Projector影响的物体都需要定制Shader.

Unity自带Projector会翻倍的原因主要也是通用性,跨平台,使用方便, 因此它的原理是
1.找到所有和Projector有碰撞的MeshRenderer
2.使用Projector的材质球,将MeshRenderer的顶点再渲染一遍,并贴图
也因此被投影的物体无法触发动态合批

那么问题来,有没有一种方案,既可以保证通用性,不需要定制被投影目标的Shader,又可以使DC不翻倍呢? 答案是:有的, 但是有代价
代价1:需要使用深度图
代价2:DC不会翻倍,但是总共的DC为,被投影物体数量 + 1. 既物体自带的DC + 1 * (Projector数量) 其实代价2根本不算个事

说搞就搞
1.开启相机的深度渲染
Camera.main.depthTextureMode |= DepthTextureMode.Depth;
2.创建一个表示投影器范围的网格,我搞了个Cube Mesh
3.创建Cube Mesh对应的相关矩阵,因为是Cube 因此创建的投影为正交投影, 当然,如果也可以使用透视投影。

BoxCollider collider = this.GetComponent();
this.m_size = collider.size.x / 2;
this.m_nearClip = -collider.size.x / 2;
this.m_farClip = collider.size.x / 2;
this.m_aspect = 1;

Matrix4x4 projector = default(Matrix4x4);
projector = Matrix4x4.Ortho(-m_aspect * m_size, m_aspect * m_size, -m_size, m_size, m_nearClip, m_farClip);

m_worldToProjector = projector * this.transform.worldToLocalMatrix;

MeshRenderer mr = this.GetComponent();
mr.sharedMaterial.SetMatrix("_WorldToProjector", m_worldToProjector);

好了,准备工作做完了。开始渲染吧。
首先是将投影器覆盖的区域,采样出当前屏幕空间的深度,类似这样的效果

要实现这样的效果,就是讲顶点变换到投影平面,并将坐标变换到UV值域下
大概这样

vert part
o.screenPos = ComputeScreenPos(o.vertex);

fragment part
fixed4 screenPos = i.screenPos;
screenPos.xy = screenPos.xy / screenPos.w;
float depth = tex2D(_CameraDepthTexture, screenPos).r;

好了,现在我们有了深度,下一部就是讲当前像素的深度还原回该深度对应的世界坐标了
只需要两部矩阵变换
1.从屏幕空间变换到相机空间 unity_CameraInvProjection
2.从相机空间变换到世界空间 unity_MatrixInvV

有了世界坐标后,就可以将该坐标变换到Projector的控件,就是准备工作中的 _WorldToProjector
变换到Projector空间后,还记得范例上的投影器的全部视野就是需要投射的贴图范围吗?因此这里要做UV值域的变换

//变换到自定义投影器投影空间
fixed4 projectorPos = mul(_WorldToProjector, worldSpacePos);
projectorPos /= projectorPos.w;

fixed2 projUV = projectorPos.xy * 0.5 + 0.5; //变换到uv坐标系
fixed4 col = tex2D(_ProjectorTex, projUV); //采样投影贴图
fixed4 mask = tex2D(_ProjectorTexMask, projUV); //采样遮罩贴图
col.rgb = lerp(fixed3(1, 1, 1), col.rgb, (1 - mask.r)); //融合

大功告成! 你可能会好奇,为什么多了一个遮罩贴图? 虽然你讲投影器视野内的像素部分都贴了投影贴图,但是是野外的像素怎么办?这个时候就需要遮罩图抹掉,因此遮罩图的纹理设置要设置为Clamp,保证边缘像素为拉伸且外侧的Alpha为0

项目完整源码:
https://github.com/dreamfairy/Unity-CubeProjector

3 comments

  1. 您好,我是一位刚刚进入游戏行业的毕业生,非常感谢这篇文章的技术分享,收获颇多,请问您是如何知道Projector这个插件的原理的呢,我一直在查,但是没找到相关的说明,希望能做指点

懒羊羊进行回复 取消回复

电子邮件地址不会被公开。 必填项已用*标注