最近有个需求,需要把物体的影子投影在某一个平面上,类似这样的效果:
打开光照阴影之后,发现在小米测试机上只有45帧,于是决定使用自定义shader,增加一个pass通道渲染阴影。在实现前,有两点需要事先说明的的:
1、这里预设接受投影得平面是已知的,并且平行于xz平面,因此阴影的y坐标是已知的。如果想了解投影到任意平面的办法,可以参考https://zhuanlan.zhihu.com/p/97267754,本文不做讨论。
2、上图中,超出平面的阴影是不显示的。但因为laya目前不支持Stencil(Stencil实现方法:Laya实现Stencil模板测试),所以“不渲染超出平面部分的阴影”这个功能在laya中无法实现。
接下来开始分析实现原理,并给出完整的shader。
如上图,P点投影到平面上的点为S,已知P点和光线方向以及(因为该平面平行于x轴和z轴组成的平面),求和。我们可以在xy平面和zy平面分别求出和。
上图是在xy平面上P与S的关系,L是光线方向(注意,既然是方向,就表示L是单位向量)。由此可得等式:
其中d是长度。已知,只要计算出d,就能得到。
可以跟据:求得:
所以:
同理可得:
接下来构建场景:
接着定义材质以及shader:
PlanarShadowMaterial继承UnlitMaterial,即物体本身使用UnlitMaterial渲染,也可以跟据需求继承BlinnPhongMaterial或其它材质。但如果继承材质基类Material,则需要自己实现贴图等材质属性的赋值,会比较麻烦。
import { UnlitMaterial } from "laya/d3/core/material/UnlitMaterial";
import { VertexMesh } from "laya/d3/graphics/Vertex/VertexMesh";
import { Shader3D } from "laya/d3/shader/Shader3D";
import { SubShader } from "laya/d3/shader/SubShader";
import UnlitVS from "../../../../../libs/laya/d3/shader/files/Unlit.vs";
import UnlitPS from "../../../../../libs/laya/d3/shader/files/Unlit.fs";
export default class PlanarShadowMaterial extends UnlitMaterial {
static PLANE_HEIGHT = Shader3D.propertyNameToID("u_Plane_Height");
constructor() {
super();
this.setShaderName("PlanarShadow");
}
public set planeHeight(val: number) {
this.shaderData.setNumber(PlanarShadowMaterial.PLANE_HEIGHT, val);
}
public get planeHeight(): number {
return this.shaderData.getNumber(PlanarShadowMaterial.PLANE_HEIGHT);
}
public static __init__() {
var vs = `
#if defined(GL_FRAGMENT_PRECISION_HIGH)
precision highp float;
#else
precision mediump float;
#endif
#include "Lighting.glsl";
attribute vec4 a_Position;
uniform mat4 u_WorldMat;
uniform mat4 u_ViewProjection;
uniform float u_Plane_Height;
uniform DirectionLight u_SunLight;
#ifdef BONE
const int c_MaxBoneCount = 24;
attribute vec4 a_BoneIndices;
attribute vec4 a_BoneWeights;
uniform mat4 u_Bones[c_MaxBoneCount];
#endif
void main() {
vec4 position;
#ifdef BONE
mat4 skinTransform;
#ifdef SIMPLEBONE
float currentPixelPos;
#ifdef GPU_INSTANCE
currentPixelPos = a_SimpleTextureParams.x+a_SimpleTextureParams.y;
#else
currentPixelPos = u_SimpleAnimatorParams.x+u_SimpleAnimatorParams.y;
#endif
float offset = 1.0/u_SimpleAnimatorTextureSize;
skinTransform = loadMatFromTexture(currentPixelPos,int(a_BoneIndices.x),offset) * a_BoneWeights.x;
skinTransform += loadMatFromTexture(currentPixelPos,int(a_BoneIndices.y),offset) * a_BoneWeights.y;
skinTransform += loadMatFromTexture(currentPixelPos,int(a_BoneIndices.z),offset) * a_BoneWeights.z;
skinTransform += loadMatFromTexture(currentPixelPos,int(a_BoneIndices.w),offset) * a_BoneWeights.w;
#else
skinTransform = u_Bones[int(a_BoneIndices.x)] * a_BoneWeights.x;
skinTransform += u_Bones[int(a_BoneIndices.y)] * a_BoneWeights.y;
skinTransform += u_Bones[int(a_BoneIndices.z)] * a_BoneWeights.z;
skinTransform += u_Bones[int(a_BoneIndices.w)] * a_BoneWeights.w;
#endif
position=skinTransform*a_Position;
#else
position=a_Position;
#endif
vec3 lightDir = normalize(u_SunLight.direction);
//计算顶点的世界坐标
vec4 shadowPos = u_WorldMat * position;
//跟据之前的公式计算Sx和Sz
shadowPos.xz = shadowPos.xz + lightDir.xz * (u_Plane_Height - shadowPos.y) / lightDir.y;
//给Sy赋值并加上一个偏移量,保证阴影在平面上方
shadowPos.y = u_Plane_Height + 0.0001;
//世界坐标转换为裁剪空间坐标
gl_Position = u_ViewProjection * shadowPos;
gl_Position=remapGLPositionZ(gl_Position);
}
`;
var fs = `
void main()
{
gl_FragColor = vec4(0, 0, 0, 0.3);
}
`;
var attributeMap = {
'a_Position': VertexMesh.MESH_POSITION0,
'a_Color': VertexMesh.MESH_COLOR0,
'a_Texcoord0': VertexMesh.MESH_TEXTURECOORDINATE0,
'a_BoneWeights': VertexMesh.MESH_BLENDWEIGHT0,
'a_BoneIndices': VertexMesh.MESH_BLENDINDICES0,
'a_MvpMatrix': VertexMesh.MESH_MVPMATRIX_ROW0
};
var uniformMap = {
'u_Bones': Shader3D.PERIOD_CUSTOM,
'u_AlbedoTexture': Shader3D.PERIOD_MATERIAL,
'u_AlbedoColor': Shader3D.PERIOD_MATERIAL,
'u_TilingOffset': Shader3D.PERIOD_MATERIAL,
'u_AlphaTestValue': Shader3D.PERIOD_MATERIAL,
'u_MvpMatrix': Shader3D.PERIOD_SPRITE,
'u_WorldMat': Shader3D.PERIOD_SPRITE,
'u_ViewProjection': Shader3D.PERIOD_CAMERA,
'u_Plane_Height': Shader3D.PERIOD_MATERIAL, //自定义的uniformMap必须使用Shader3D.PERIOD_MATERIAL类型
'u_SunLights': Shader3D.PERIOD_SCENE,
'u_SimpleAnimatorTexture': Shader3D.PERIOD_SPRITE,
'u_SimpleAnimatorParams': Shader3D.PERIOD_SPRITE,
'u_SimpleAnimatorTextureSize': Shader3D.PERIOD_SPRITE,
'u_FogStart': Shader3D.PERIOD_SCENE,
'u_FogRange': Shader3D.PERIOD_SCENE,
'u_FogColor': Shader3D.PERIOD_SCENE
};
var stateMap = {
's_Cull': Shader3D.RENDER_STATE_CULL,
's_Blend': Shader3D.RENDER_STATE_BLEND,
's_BlendSrc': Shader3D.RENDER_STATE_BLEND_SRC,
's_BlendDst': Shader3D.RENDER_STATE_BLEND_DST,
's_DepthTest': Shader3D.RENDER_STATE_DEPTH_TEST,
's_DepthWrite': Shader3D.RENDER_STATE_DEPTH_WRITE
}
var shader = Shader3D.add("PlanarShadow", null, null, true);
var subShader = new SubShader(attributeMap, uniformMap);
subShader.addShaderPass(vs, fs, stateMap);
subShader.addShaderPass(UnlitVS, UnlitPS, stateMap);
shader.addSubShader(subShader);
}
}
调用方法:
PlanarShadowMaterial.__init__();
let cube = this.view.getChildByName("Cube") as MeshSprite3D;
let material = new PlanarShadowMaterial();
material.planeHeight = (this.view.getChildByName("Plane") as Sprite3D).transform.position.y;
material.renderMode = UnlitMaterial.RENDERMODE_TRANSPARENT;
material.depthWrite = true;
cube.meshRenderer.material = material;