大家好,我是阿赵。
之前的文章介绍过怎么自己去写光照模型效果,后来我发现漏了一样比较重要的东西。那就是影子的产生。
由于我写的Shader都是以unlit的顶点片段程序开始写的,所以写出来的shader都是只有物体本身有模拟光照效果,而物体是既不会接收别的物体的投影,自己也不会产生投影的。
但作为一个立体效果的一部分,投影也是很重要的,特别是跟随这物体运动而产生变化的投影。
所以这里就不讨论那种用一个圆形面片给角色加个假影子这种做法了。不是说这种做法不好,其实我的项目在低质量的时候也是用圆形假影子的。只是这种方法太简单,没太多值得讨论的地方。
下面介绍3种做动态影子的方法,都是我在项目里面使用过的,可以给大家参考一下。
一、用实时灯光渲染影子
这种方式其实就是用了Unity的内置灯光系统
本来这个没什么好说的,但由于之前的光照模型都是不会受灯光阴影影响的,所以这里也提供一下如果是自定义光照模型的情况下,怎样去接受和产生光影
1、完整的shader
Shader "Unlit/LightShadowTest"
{
Properties
{
_Color("Color",Color) = (1,1,1,1)
_ShadowColor("ShadowColor",Color) = (0,0,0,1)
_ShadowIntensity("ShadowIntensity",Range(0,1)) = 1
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
//这个pass是在正常编写光照模型的基础上,加上了接受投影
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
//要获取灯光信息,这两行是必须的
#pragma multi_compile_fwdbase
#include "AutoLight.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
//由于上面已经用了TEXCOORD0和TEXCOORD1,所以把阴影数据指定在TEXCOORD2,这里要根据自己的实际情况来指定
SHADOW_COORDS(2)
};
//获取Lambert漫反射值,这里只是为了让模型有基本光照效果,不是必须的
float GetLambertDiffuse(float3 worldPos, float3 worldNormal)
{
float3 lightDir = UnityWorldSpaceLightDir(worldPos);
float NDotL = saturate(dot(worldNormal, lightDir));
return NDotL;
}
float4 _Color;
float4 _ShadowColor;
float _ShadowIntensity;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
//这里必须注意,为了用TRANSFER_SHADOW方法,v2f的顶点位置变量名一定要是pos,如果用了其他名字,会报错
//invalid subscript 'pos' 'ComputeScreenPos': no matching 1 parameter function (on d3d11)
TRANSFER_SHADOW(o);
return o;
}
half4 frag (v2f i) : SV_Target
{
fixed4 col = _Color* GetLambertDiffuse(i.worldPos,i.worldNormal);
//需要注意,atten变量不需要声明,但一定要用UNITY_LIGHT_ATTENUATION方法先赋值,不然会报错
//undeclared identifier 'atten'
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
//atten值就是关键,他代表了产生影子的实际范围,所以下面用atten来做插值,混合漫反射颜色和影子颜色
half3 finalCol = col.rgb*atten+col.rgb*_ShadowColor*(1 - atten);
return half4(finalCol,1);
}
ENDCG
}
//为了产生影子,加多一个pass
Pass {
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct v2f {
V2F_SHADOW_CASTER;
};
v2f vert(appdata_base v)
{
v2f o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
float4 frag(v2f i) : SV_Target
{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
}
}
2、说明
在上面的shader代码里面,我已经把各个部分做了注释,这里说一下重点的地方
1.接受影子部分
(1)必要的引用
#pragma multi_compile_fwdbase
#include “AutoLight.cginc”
(2)v2f结构
SHADOW_COORDS(2)
这个代码的意思是把阴影的数据存放在TEXCOORD2。这里不一定是2的,要根据前面已经用了多少个TEXCOORD来定
(3)顶点程序
TRANSFER_SHADOW(o);
这里必须注意,为了用TRANSFER_SHADOW方法,v2f的顶点位置变量名一定要是pos,如果用了其他名字,会报错:invalid subscript ‘pos’ ‘ComputeScreenPos’: no matching 1 parameter function (on d3d11)
(4)片段程序
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
需要注意,atten变量不需要声明,但一定要用UNITY_LIGHT_ATTENUATION方法先赋值,不然会报错:undeclared identifier ‘atten’
atten值就是关键,他代表了产生影子的实际范围,所以下面用atten来做插值,混合漫反射颜色和影子颜色
2.产生影子部分
(1)必要引用
#pragma multi_compile_shadowcaster
Tags { “LightMode” = “ShadowCaster” }
(2)v2f结构体
V2F_SHADOW_CASTER;
(3)顶点程序
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
(4)片段程序
SHADOW_CASTER_FRAGMENT(i)
做了这些事情之后,原来的茶壶就变得可以接受阴影和产生阴影了。
不过这种方式做实时阴影,在手机平台上面有可能会不能正常显示,所以下面继续介绍通过别的手段来控制投影的方式。
二、顶点位置实现面片影子
这个方法,实际上是写多一个pass,把模型顶点坐标做修改,改到在模型的脚下一个面片,然后填充颜色。
1、完整的shader
Shader "AzhaoPlanarShadow"
{
Properties
{
_MainColor("MainColor",Color) = (1,1,1,1)
_ShadowColor("ShadowColor",Color) = (0,0,0,0.5)
_GroundHeight("GroundHeight",float) = 0
_ShadowFallOff("ShadowFallOff",Range(0,1)) = 1
}
SubShader
{
Tags { "RenderType"="Opaque"}
LOD 100
//这个pass是正常的漫反射颜色,随便用了一个Lambert光照模型,可以不看
Pass
{
Cull off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal:NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
};
float4 _MainColor;
//获取Lambert漫反射值
float GetLambertDiffuse(float3 worldPos, float3 worldNormal)
{
float3 lightDir = UnityWorldSpaceLightDir(worldPos);
float NDotL = saturate(dot(worldNormal, lightDir));
return NDotL;
}
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
half4 frag(v2f i) : SV_Target
{
half4 col = _MainColor;
float diffuse = GetLambertDiffuse(i.worldPos, i.worldNormal);
col *= diffuse;
return col;
}
ENDCG
}
//专门写一个pass,把模型的顶点拍扁,放在模型脚底下,主要看顶点程序就可以了
Pass
{
Name "Shadow"
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
float falloff : TEXCOORD0;
};
float4 _ShadowColor;
float _GroundHeight;
float _ShadowFallOff;
v2f vert (appdata v)
{
v2f o;
float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
float3 shadowPos;
//需要指定一个地面高度_GroundHeight,因为这是模拟的阴影,shader并不知道地面在哪里。
//比地面低的地方不需要显示影子
shadowPos.y = min(worldPos.y, _GroundHeight);
//求出拍扁后的影子在光照方向转换后的顶点位置
shadowPos.xz = worldPos.xz - lightDir.xz*max(0, worldPos.y - _GroundHeight) / lightDir.y;
//求出影子顶点坐标的裁剪空间坐标,用于实际显示
o.vertex = UnityWorldToClipPos(shadowPos);
//由于是单个模型的顶点计算,所以可以沿着模型中心点往外扩散做一个渐变
float3 center = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz;
center.y = shadowPos.y;
//stepVal是为了求出比地面高的部分才显示。虽然上面限制了shadowPos的y坐标,但如果只是单纯压扁,还是会看得到。
float stepVal = step(0.01, worldPos.y - _GroundHeight);
float falloff = saturate(distance(shadowPos, center)*_ShadowFallOff);
o.falloff = (1 - falloff)*stepVal;
return o;
}
half4 frag (v2f i) : SV_Target
{
half4 col = _ShadowColor;
col.a *= i.falloff;
return col;
}
ENDCG
}
}
}
2、说明
上面的代码我已经加了比较详细的备注,主要看第2个pass的顶点程序部分。
需要说明的是,这种方式需要指定一个地面高度,不然shader不知道需要投影的地面在哪里。如果是在rpg游戏里面,可以给角色身上挂一个C#脚本,往地面发射射线来求出地面高度。或者如果游戏本身的地形不会起伏,那么直接用全局变量设置一个地面高度也可以。
然后由于这是单个模型的顶点变化,所以我们可以做到单个影子做渐变。代码里面的falloff,就是从角色中心点到影子边缘的一个渐变效果,如果觉得没必要,也可以不加。
3、优缺点
优点
1.实现简单,只需要写一个shader就行
2.可以加各种效果,比如调整颜色、渐变等
缺点
由于他是一个面片,所以并不会在起伏的地面上产生变化的投影,而是会直接穿过别的模型
所以如果是在起伏的地形上面用这个方式,影子会和地面穿插。但如果只是用于平坦的地面,是没有问题的。要根据自己的游戏实际情况判断这种方式是否适合。
三、用Projector实现影子
1、原理
首先知道,Unity有一个叫做Projector的组件,是用于把一张图片投影到模型上面的。投影的效果会根据模型的网格形状变化。它上面的参数,看起来和Camera上面的很多参数都很像。
然后,如果想用Projector模拟灯光照射在模型上面的投影,我们需要把需要产生投影的模型单独放一个Layer,然后用一个额外的摄像机,拍摄这个Layer里面的物体,再把得到的图片通过矩阵转换,放到Projector的材质球上面。这样,Projector就把这种拍摄到的图片经过转换位置后重新投影回场景,刚好和原来的物体对应上,看起来就好像是真实的动态影子。
通过原理分析,就能知道,我们需要以下这些东西来实现这个效果
1.Projector组件
2.一个额外的摄像机
3.一个处理Projector使用的图片转换的Shader
然后由于额外摄像机会拍摄场景里面产生影子的模型,但这些模型本身可能已经有比较复杂的shader,我们是不需要全部渲染出效果的,所以我们还需要一个最简单的效果的Shader,用于渲染这些模型的简单黑白轮廓。
2、代码
1.C#代码
为了使用简单,我这个代码里面会自动创建实现效果所需的所有组件,所以只需要建个空物体,然后把脚本拖上去,再把投影器用的shader的材质球拖上去,就可以了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
[RequireComponent(typeof(Projector))]
public class ProjectorCtrl : MonoBehaviour
{
public LayerMask shadowLayer;
public float shadowSize = 1;
public string ReplaceShaderName = "Unlit/ReplaceShadowRender";
private Camera renderCam;
private Projector projector;
private RenderTexture shadowTexture;
private bool isRendering = false;
private Shader replaceShader;
private Matrix4x4 camProjectionMatrix;
private Matrix4x4 matVP;
void Start()
{
}
void OnEnable()
{
OnRenderObject();
}
//销毁的时候,同时销毁生成的RenderTexture
private void OnDestroy()
{
if(renderCam!=null)
{
renderCam.targetTexture = null;
}
if(shadowTexture!=null)
{
GameObject.Destroy(shadowTexture);
shadowTexture = null;
}
}
//自动获取和生成需要的组件,设置各种参数
private void CheckAllObj()
{
CheckRt();
CheckProjector();
CheckCam();
}
//创建RenderTexture,其中的宽高可以根据自己实际情况来指定
private void CheckRt()
{
if(shadowTexture == null)
{
shadowTexture = new RenderTexture((int)(Screen.width * shadowSize), (int)(Screen.height * shadowSize), 0);
shadowTexture.hideFlags = HideFlags.DontSave;
}
}
//获取投影器,并把生成的RenderTexture传进去材质球
private void CheckProjector()
{
if(projector == null)
{
projector = gameObject.GetComponent<Projector>();
projector.ignoreLayers = shadowLayer;
if(projector.material != null && projector.material.HasProperty("_ShadowTex"))
{
projector.material.SetTexture("_ShadowTex", shadowTexture);
}
projector.enabled = true;
}
}
//检查用于渲染RenderTexture的摄像机
private void CheckCam()
{
if(renderCam == null)
{
CreateSelfCam();
}
}
//创建渲染RenderTexture的摄像机,复制投影器上的参数,并指定渲染目标的RenderTexture
private void CreateSelfCam()
{
renderCam = gameObject.GetComponent<Camera>();
if(renderCam == null)
{
renderCam = gameObject.AddComponent<Camera>();
}
renderCam.nearClipPlane = projector.nearClipPlane;
renderCam.farClipPlane = projector.farClipPlane;
renderCam.fieldOfView = projector.fieldOfView;
renderCam.clearFlags = CameraClearFlags.SolidColor;
renderCam.backgroundColor = new Color(0,0,0,1);
renderCam.orthographic = projector.orthographic;
renderCam.orthographicSize = projector.orthographicSize;
renderCam.aspect = projector.aspectRatio;
renderCam.depthTextureMode = DepthTextureMode.None;
renderCam.renderingPath = RenderingPath.Forward;
renderCam.cullingMask = shadowLayer;
renderCam.enabled = false;
renderCam.targetTexture = shadowTexture;
}
// Update is called once per frame
void OnPreRender()
{
OnRenderObject();
}
//实际渲染时调用
private void OnRenderObject()
{
if(isRendering == true)
{
return;
}
isRendering = true;
CheckAllObj();
CheckCamPos();
CreateCameraProjecterMatrix();
projector.material.SetMatrix("ShadowMatrix", matVP);
renderCam.RenderWithShader(GetReplaceShader(), "");
isRendering = false;
}
//需要生成阴影的模型身上可能有很复杂的shader,但由于我们只需要一个黑白颜色的阴影图,所以并不需要使用模型本身的shader来渲染
//通过一个最简单的shader来渲染模型就可以
private Shader GetReplaceShader()
{
if(string.IsNullOrEmpty(ReplaceShaderName))
{
return null;
}
if(replaceShader == null)
{
replaceShader = Shader.Find(ReplaceShaderName);
}
return replaceShader;
}
//获得投影矩阵
void CreateCameraProjecterMatrix()
{
camProjectionMatrix = GL.GetGPUProjectionMatrix(renderCam.projectionMatrix, true);
matVP = camProjectionMatrix * renderCam.worldToCameraMatrix;
}
#region 自动计算投影器位置
public float angleX = 0;
public float angleY = 0;
private float px = 0;
private float py = 0;
private float pz = 0;
public float distance = 10;
public Transform lightObj;
private void CheckCamPos()
{
if(lightObj!=null)
{
Vector3 lightAngle = lightObj.rotation.eulerAngles;
angleX = lightAngle.y * -1;
angleY = 90 - lightAngle.x;
}
if(Camera.main == null)
{
return;
}
GameObject camGo = Camera.main.gameObject;
Vector3 tPos = camGo.transform.forward * distance + camGo.transform.position;
renderCam.orthographicSize = projector.orthographicSize = distance;
//this.transform.parent = null;
float tx = tPos.x;
float ty = tPos.y;
float tz = tPos.z;
pz = distance * Mathf.Sin(angleY * Mathf.Deg2Rad);
py = distance * Mathf.Cos(angleY * Mathf.Deg2Rad);
px = pz * Mathf.Sin(angleX * Mathf.Deg2Rad);
pz = pz * Mathf.Cos(angleX * Mathf.Deg2Rad);
base.transform.position = new Vector3(tx + px, ty + py, tz - pz);
float rx = Mathf.Atan(pz / py) * Mathf.Rad2Deg;
base.transform.rotation = Quaternion.Euler(new Vector3(90 - angleY, -angleX, 0));
}
#endregion
}
需要注意的是,投影器的实现不复杂,但投影灯光角度和投影器的角度有关系,投影器的范围要和主摄像机的位置有关系,这样才能当主摄像机移动的时候,投影器会跟着主摄像机一起移动,并且设置在合适的角度。
所以代码里面,有一段自动计算投影器位置的代码,特殊标记了出来,如果不调用CheckCamPos,那投影器的位置和旋转就不会自动设置。由于我想通过灯光的旋转来控制影子的角度,所以加多了一个lightObj,如果指定了灯光对象,那么投影器的旋转会以灯光方向来控制。
这个c#脚本需要配合2个shader来使用。一个是拖到Projector上面的材质球,一个是用于渲染时替代的Unlit/ReplaceShadowRender。
然后这个脚本做了一些矩阵的获取计算。如果在摄像机的位置和旋转发生改变的情况下,投影矩阵是需要重新计算的。所以根据自己的实际情况,可以加一些优化,当摄像机位置发生改变时,才去重新计算投影矩阵和Projector的位移旋转,这将减少很多的计算量。
2.Shader
(1)ProjectorShadow
这个shader是作为挂在Projector上面的材质球使用的,输入RenderTexture、影子的强度、影子遮罩、影子颜色、投影矩阵,控制影子实际显示的效果。
Shader "ProjectorShadow" {
Properties{
_ShadowTex("_ShadowTex", 2D) = "gray" {}
_Bias("_Bias", Range(0, 0.01)) = 0
_Strength("_Strength", Range(0, 1)) = 0.1
_ShadowMask("ShadowMask",2D) = "white"{}
_ShadowColor("ShadowColor",Color) = (1,1,1,1)
}
Subshader{
Tags {"Queue" = "Transparent"}
Pass {
ZWrite Off
AlphaTest Greater 0
Blend SrcAlpha OneMinusSrcAlpha
Offset -1, -1
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f {
float4 uvShadow : TEXCOORD0;
float4 pos : SV_POSITION;
};
uniform float4x4 ShadowMatrix;
float4x4 unity_Projector;
float4x4 unity_ProjectorClip;
v2f vert(appdata_base v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float4x4 matWVP = mul(ShadowMatrix, unity_ObjectToWorld);
//把影子的顶点转换到投影空间,作为对RenderTexture采样的UV坐标
o.uvShadow = mul(matWVP, v.vertex);
return o;
}
sampler2D _ShadowTex;
sampler2D _ShadowMask;
sampler2D _FalloffTex;
float _Bias;
float _Strength;
float4 _ShadowColor;
half4 frag(v2f i) : SV_Target
{
//将得到的影子UV坐标变换到齐次坐标,并且把范围控制在0-1之间
half2 uv = i.uvShadow.xy / i.uvShadow.w * 0.5 + 0.5;
#if UNITY_UV_STARTS_AT_TOP
uv.y = 1 - uv.y;
#endif
half4 finalCol = half4(_ShadowColor.rgb,0);
half4 shadowCol = tex2D(_ShadowTex, uv);
float stepVal = step(0.01, shadowCol.r);
finalCol.a =saturate(finalCol.a + _Strength * stepVal);
//由于_ShadowTex的Clamp类型,所以到了边缘的地方会产生拉伸的横纹,这里加一张遮罩图,控制RenderTexture到达边缘的时候不显示阴影
half maskCol = tex2D(_ShadowMask, uv).r;
finalCol.a = finalCol.a* maskCol;
return finalCol;
}
ENDCG
}
}
}
代码里面已经写了比较详细的备注,所以这里不再多说了。值得注意的是,由于RenderTexture是作为投影器的贴图投在场景里面的,所以必然会存在投影器边缘,由于RenderTexture不是Repeat类型而是Clamp类型,所以在边缘会产生一些拉伸的效果。为了解决这个问题,所以加了一张ShadowMask贴图,这张贴图是边缘黑中间白的渐变图,目的是让Projector的边缘不要显示出错误的拉伸。
(2)ReplaceShadowRender
这个shader是用于渲染RenderTexture的摄像机RenderWithShader时的替代shader,摄像机将会以这个最简单的形式,只是渲染出模型的黑白轮廓,用于投影贴图。
Shader "Unlit/ReplaceShadowRender"
{
Properties
{
}
SubShader
{
Tags { "RenderType" = "Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
};
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 col = fixed4(1,1,1,1);
return col;
}
ENDCG
}
}
}
这个shader没什么好说的了,就是正常的把顶点坐标转换到裁剪空间,然后填充了个白色。
3、说明
运行之后,会看到影子生成出来了,而且这个影子是可以影响到场景的其他物体的。
下面来说一下优缺点:
优点
1.比起面片影子,这种方式不需要指定地面高度
2.比起面片影子,这种方式可以随着地形起伏而变化投影,比较真实
缺点
1.实现比较复杂,计算量比较大
2.由于投影摄像机是通过Layer来过滤渲染物体,所以地面不会参与计算,这样会导致如果物体沉入地面的时候,影子会出错,还要另外想办法移除被地面遮挡的部分。
3.由于Projector上面的Shader是处理整张RenderTexture的,所以不能在投影器上面做单个影子的渐变,如果想做,可以在ReplaceShader上面试试。
四、总结
不论是哪种方式,想要实现动态投影,都是要把模型多渲染一次的,前两种方法是多加了一个Pass来渲染影子,最后一种方法是增加多了一个摄像机来渲染影子。所以,当开启了动态影子之后,产生投影的模型本身的渲染面数是会翻倍的。
既然动态影子会消耗额外的性能,所以在使用的时候要注意。一般来说,我自己的习惯是会在游戏内给玩家选择不同的显示质量,在最低质量的时候,直接就不显示真实的动态投影,而改为用圆形面片代替,当玩家选择中高质量的时候,才会开启动态投影。