作业总览
实现Shadow Map。
实现PCF。
实现PCSS。
实现多光源和动态物体。(Bonus)
源码
GAMES101&202 Homework
关于作业框架加载不出模型
作业框架有大概率加载不出202娘,如上图所示。
1 2 //index.html <link rel ="preload" href ="/assets/mary/MC003_Kozakura_Mari.png" as ="image" type ="image/png" crossorigin />
解决方法是在index.html
的21行加入以上代码即可,方案来自GAMES的202课程BBS帖子GAMES202 课程BBS-作业0 结果不稳定,有时模型显示不全 。
关于作业框架中的ShadowMap流程
作业框架由js+webgl实现,js可能不是问题,但对于没有opengl/webgl基础的同学,毕竟在GAMES101/202没讲过图形API的使用,某些流程可能不太理解,这里对核心流程做个简单说明。
先复习一下ShadowMap的原理,相比起直接对场景进行无阴影的渲染,ShadowMap阴影流程需要多一个Pass,第一个Pass是先从光源点向光源方向作为视角渲染出一张深度图,也就是所谓的ShadowMap,第二个Pass再从当前摄像机渲染真实的场景,在渲染场景时把像素点变换到光源空间中,取其在光源空间中的深度与在ShadowMap中同一个uv坐标所记录的深度作对比,若大于ShadowMap上的深度,则该点在阴影中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 if (this .lights[l].entity.hasShadowMap == true ) { for (let i = 0 ; i < this .shadowMeshes.length; i++) { this .shadowMeshes[i].draw(this .camera); } } for (let i = 0 ; i < this .meshes.length; i++) { this .gl.useProgram(this .meshes[i].shader.program.glShaderProgram); this .gl.uniform3fv(this .meshes[i].shader.program.uniforms.uLightPos, this .lights[l].entity.lightPos); this .meshes[i].draw(this .camera); }
两个Pass的绘制流程体现在WebGLRenderer.js
以上代码中。其中shadowMeshes和meshes最终其实指向的是同一堆mesh数据,只是其材质不一样,这一点可以在脚本loadOBJ.js
中看到。
在shadow pass中,会以光源位置朝向光源方向的视角,把场景渲染一遍,其中顶点着色器使用shadowVertex.glsl
,片元着色器使用shadowFragment.glsl
。注意shadow pass并不会直接把结果渲染到屏幕的缓存中,而是渲染到属于该光源的FrameBuffer中,这一点在DirectionalLight.js
可以看到有代码this.fbo = new FBO(gl);
,这段代码会创建属于这个光源实例的FrameBuffer,最终会储存在Material中,当对应的MeshRender被调用Draw方法时,通过gl.bindFramebuffer(gl.FRAMEBUFFER, this.material.frameBuffer);
来绑定到材质持有的FrameBuffer,所以shadow pass就会把结果渲染到自己的FrameBuffer上。
在camera pass中,会以真实摄像机视角,把场景渲染一遍,其中顶点着色器使用phongVertex.glsl
,片元着色器使用phongFragment.glsl
。另外提一下,用于在camera pass渲染的PhongMaterial,是没有frameBuffer参数的,也就是其Material中frameBuffer字段为空,但当他的MeshRender的Draw被调用时候,仍然会执行gl.bindFramebuffer(gl.FRAMEBUFFER, this.material.frameBuffer);
,但绑定到空的FrameBuffer上相当于绑定到默认FrameBuffer(屏幕)上,所以最终绘制结果是在屏幕。
shadow pass的渲染结果会传递到phongFragment的uniform数据中,以贴图形式供着色器采样以渲染阴影。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #ifdef GL_ES precision mediump float ; #endif uniform vec3 uLightPos; uniform vec3 uCameraPos; varying highp vec3 vNormal; varying highp vec2 vTextureCoord; vec4 pack (float depth) { const vec4 bitShift = vec4(1.0 , 256.0 , 256.0 * 256.0 , 256.0 * 256.0 * 256.0 ); const vec4 bitMask = vec4(1.0 /256.0 , 1.0 /256.0 , 1.0 /256.0 , 0.0 ); vec4 rgbaDepth = fract(depth * bitShift); rgbaDepth -= rgbaDepth.gbaa * bitMask; return rgbaDepth; } void main () { gl_FragColor = pack(gl_FragCoord.z); }
在shadowFragment有两个值得注意的点,一个是gl_FragCoord,另一个是pack函数。
gl_FragCoord是GLSL提供的vec4类型内置变量,其中xyz表示窗口空间坐标(window-space coordinate),窗口空间坐标是什么坐标?是NDC坐标经过viewport transformation后得到的坐标,在opengl中,其大小范围是x:[0, ScreenWidth],y:[0, ScreenHeight],z:[0, 1]。这里我们使用了z值,能直接表示片元的深度关系,至于此深度值是否线性,取决于投影矩阵,因为平行光使用的是正交矩阵,所以gl_FragCoord.z取得的深度值是线性的。而gl_FragCoord.w则是裁剪空间坐标中w的倒数。
pack函数的作用是把一个[0,1)
的float值储存到RGBA四个通道中,pack的实现最早似乎可以追溯到 Encoding floats to RGBA - the final? 这篇文章,网上流传着255和256两个版本,作业使用的是256版本,但似乎255效果更好。相对应的在phongFragment.glsl
中还有一个unpack函数,因为shadowmap中储存的值是pack后的值,我们在采样后需要unpack后才能使用。在引擎中其实也有这两个函数,例如在Unity中对应的就是EncodeFloatRGBA和DecodeFloatRGBA。
实现
光源生成ShadowMap所用的MVP矩阵
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 CalcLightMVP (translate, scale ) { let lightMVP = mat4.create(); let modelMatrix = mat4.create(); let viewMatrix = mat4.create(); let projectionMatrix = mat4.create(); mat4.translate(modelMatrix, modelMatrix, translate) mat4.scale(modelMatrix, modelMatrix, scale) mat4.lookAt(viewMatrix, this .lightPos, this .focalPoint, this .lightUp) var r = 100 ; var l = -r; var t = 100 ; var b = -t; var n = 0.01 ; var f = 200 ; mat4.ortho(projectionMatrix, l, r, b, t, n, f); mat4.multiply(lightMVP, projectionMatrix, viewMatrix); mat4.multiply(lightMVP, lightMVP, modelMatrix); return lightMVP; }
这里需要计算的是光源生成ShadowMap所用的MVP矩阵,原理在GAMES101已经学习过,这里不再赘述。这里不需要我们自行计算矩阵变换,我们只需要调用现有接口指定对应参数即可。
这里用到的库是一个叫glMatrix
的Javascript矩阵和矢量库,具体可参考官方文档 。
注意因为我们实现的是平行光阴影,投影矩阵可用正交矩阵,变换后仍然保持线性深度,另外注意正交矩阵的参数定义,这里应该用尽可能小的参数,以使得ShadowMap的精度尽可能大。
useShadowMap实现
在实现useShadowMap之前,我们先来看看useShadowMap该怎么调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void main (void ) { vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w; shadowCoord.xyz = (shadowCoord.xyz + 1.0 ) / 2.0 ; float visibility; visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0 )); vec3 phongColor = blinnPhong(); gl_FragColor = vec4(phongColor * visibility, 1.0 ); }
由于我们需要在ShadowMap上采样,采样使用的是uv坐标,那么我们传进useShadowMap的坐标的xy范围应该在[0,1],而又因为ShadowMap储存的值unpack之后是在[0,1)的范围,我们需要在这个范围区间做深度比较,所以z分量同样也在这个区间。
为了求出这3个值,我们可以利用vPositionFromLight这个数据,它是由顶点着色器中把顶点坐标乘以uLightMVP(相当于以灯源作为摄像机的MVP矩阵)得到的,也就是光源空间下投影变换后的裁剪坐标,我们第一步把他除以自己的w值,即可得到NDC坐标,此时坐标范围在[-1,1]区间,第二步把他转换到[0,1]区间中即可。
然后我们把参数传入useShadowMap函数,函数应该返回两种值,该点处于阴影中时返回0,处于光照范围时返回1,然后我们把返回值visibility乘以着色结果phongColor并赋值给gl_FragColor输出最终结果即可。
1 2 3 4 5 6 7 8 9 10 11 float useShadowMap (sampler2D shadowMap, vec4 shadowCoord) { float depth = unpack(texture2D(shadowMap, shadowCoord.xy)); float cur_depth = shadowCoord.z; if (cur_depth > depth + EPS){ return 0. ; } else { return 1.0 ; } }
useShadowMap的实现很简单,用texture2D传入shadowMap贴图和对应uv坐标即可,然后把结果unpack就是在光源视角的当前位置点遮挡物深度,若当前位置点的深度大于遮挡物深度,则表示处于阴影中。
完成后我们即可得到下面结果。
Shadow Bias
虽然得到了阴影的结果,但当我们换一个姿势观察的时候发现阴影有点问题,这就是课程中提到的由于ShadowMap精度不足(ShadowMap上的一个像素对应了实际渲染场景中的一片区域,但其实这片区域的深度并不相同)导致的自遮挡问题,称为Shadow Acne。
解决这个问题有很简单的办法,在对比深度时,只需要加上一个自己定义的数值的bias即可。
加上bias后效果如上图,虽然我们解决了原本的问题,但又另外引入了一个新的问题,由bias引起的漏光问题,为了优化漏光的问题,我们需要根据实际场景调整bias的值,但这个值很可能随着环境情况变化而变化,我们或许可以有根据相关参数调节bias值的方法。
自适应Shadow Bias算法
参照此文章给出的公式
A = ( 1 + c e i l ( R ) ) ∗ f r u s t u m S i z e s h d o w M a p S i z e ∗ 2 \Large A = (1 + \mathbf ceil(R))* \frac {frustumSize}{shdowMapSize * 2} A = ( 1 + c e i l ( R )) ∗ s h d o wM a pS i ze ∗ 2 f r u s t u m S i ze
B = 1 − d o t ( l i g h t D i r , n o r m a l ) \large B = 1 - \mathbf dot(lightDir, normal) B = 1 − d o t ( l i g h t D i r , n or ma l )
D e p t h B i a s = C d e p t h ∗ A ∗ B \large Depth Bias = C_{depth} * A* B De pt h B ia s = C d e pt h ∗ A ∗ B
我们添加一个getShadowBias函数
1 2 3 4 5 6 7 float getShadowBias (float c, float filterRadiusUV) { vec3 normal = normalize(vNormal); vec3 lightDir = normalize(uLightPos - vFragPos); float fragSize = (1. + ceil (filterRadiusUV)) * (FRUSTUM_SIZE / SHADOW_MAP_SIZE / 2. ); return max(fragSize, fragSize * (1.0 - dot(normal, lightDir))) * c; }
其中参数c是我们可以调节的一个最终系数,而参数filterRadiusUV是当使用PCF时,自适应还得考虑PCF的采样范围,但我们实现目前暂时用不到。
由于自适应算法与视锥体大小和ShadowMap大小有关,我们直接用#define定义这两个数据。
1 2 #define SHADOW_MAP_SIZE 2048. #define FRUSTUM_SIZE 400.
并修改useShadowMap函数
1 2 3 4 5 6 7 8 9 10 11 12 float useShadowMap (sampler2D shadowMap, vec4 shadowCoord, float biasC, float filterRadiusUV) { float depth = unpack(texture2D(shadowMap, shadowCoord.xy)); float cur_depth = shadowCoord.z; float bias = getShadowBias(biasC, filterRadiusUV); if (cur_depth - bias >= depth + EPS){ return 0. ; } else { return 1.0 ; } }
注意这里给useShadowMap函数添加了两个参数,最后我们还需要调整main函数中对useShadowMap函数的调用,其中bias可以执行根据效果设定,而由于我们第一步是做硬阴影(嘤嘤嘤)所以filterRadiusUV参数传0。
1 2 3 float bias = .4 ;visibility = useShadowMap(uShadowMap, vec4 (shadowCoord, 1.0 ), bias, 0. );
然后我们可以得出一下效果
可以看到明显改善了Shadow Acne问题。
PCF
因为ShadowMap是有分辨率的,同样会因为精度问题产生走样,因为对ShadowMap采样后做深度比较是非0即1的结果,导致阴影边界非常生硬。
PCF(Percentage Closer Filtering)把在ShadowMap采样后做深度比较的结果相加后进行平均,得到一个模糊的结果,把这个模糊的结果作为visibility项,即可使得阴影边界变得柔和。
PCF一开始是用于阴影抗锯齿上,后来人们发现可以用来实现软阴影,采样范围越大,阴影越“软”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 float PCF (sampler2D shadowMap, vec4 coords, float biasC, float filterRadiusUV) { poissonDiskSamples(coords.xy); float visibility = 0.0 ; for (int i = 0 ; i < NUM_SAMPLES; i++){ vec2 offset = poissonDisk[i] * filterRadiusUV; float shadowDepth = useShadowMap(shadowMap, coords + vec4(offset, 0. , 0. ), biasC, filterRadiusUV); if (coords.z > shadowDepth + EPS){ visibility++; } } return 1.0 - visibility / float (NUM_SAMPLES); }
PCF实现首先需要两个参数,一是采样范围,二是采样数量,然后我们需要描述如何在指定范围内采样到指定数量的样本。
作业框架提供了uniformDiskSamples和poissonDiskSamples两个随机采样函数,我们调用即可,采样函数要求我们传入一个vec2变量作为随机种子,我们直接使用片元坐标即可,注意不要使用固定值,否则每次采样结果都是一样的。我们也可以调整NUM_SAMPLES
来修改采样数,采样数越高,噪点越少,效果越好。
调用采样函数后,会把采样结果储存到数组poissonDisk中,我们把结果乘以我们指定的一个范围值(这里作为参数传入),然后把结果作为原始采样坐标的offset传入useShadowMap即可。然后把useShadowMap得到的结果做深度比较,计算出被遮掩的样本数量占总样本数量占比,就是我们需要的模糊结果,注意由于返回的是visibility项,我们需要用1减去模糊结果,把意义取反。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void main (void ) { vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w; shadowCoord.xyz = (shadowCoord.xyz + 1.0 ) / 2.0 ; float visibility = 1. ; float nonePCFBiasC = .4 ; float pcfBiasC = .2 ; float filterRadiusUV = FILTER_RADIUS / SHADOW_MAP_SIZE; visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0 ), pcfBiasC, filterRadiusUV); vec3 phongColor = blinnPhong(); gl_FragColor = vec4(phongColor * visibility, 1.0 ); }
然后修改main函数,以PCF函数返回值作为visibility项,并传入对应的bias和采样范围。
1 #define FILTER_RADIUS 10.
采样范围我们直接定义,这里定义的单位是在ShadowMap大小的单位,因为采样时用的是uv单位,我们需要把采样范围除以ShadowMap大小作为参数。
使用poissonDiskSamples采样函数,并把NUM_SAMPLES
调整为50,效果如下:
PCSS
上图可以看出,在真实的光照阴影中,靠近遮挡物的部分,阴影边界会比较锐利,而远离遮挡物的阴影边界则比较模糊。
在游戏中,我们可以用PCSS来实现这种近实远虚的软阴影效果。
PCSS的核心在于,如何确定一个点的虚实程度,一个简单的规则,当这个点离遮挡点越远的时候,阴影显得越虚,当然这个还得跟光源大小等相关,根据相似三角形,我们可以得出公式:
W p e n u m b r a = ( d R e c e i v e r − d B l o c k e r ) ∗ W l i g h t / d B l o c k e r \large W_{penumbra} = (d_{Receiver} - d_{Blocker}) * W_{light} / d_{Blocker} W p e n u mb r a = ( d R ece i v er − d Bl oc k er ) ∗ W l i g h t / d Bl oc k er
得出来的这个数决定了阴影的虚实程度。
在上述公式中,receiver的深度是已知的,light的大小是我们设定的,那么剩下的是遮挡物blocker的深度了,为了结果更准确,我们在一个范围里计算这个点遮挡物的平均深度,那么我们又有了一个新的问题,这个范围取多少呢?能否根据相关联的数据计算出合适的范围?
这张图给了我们一种方案,我们可以从shading point连接到光源,其经过Shadow Map时所截取的面积,就是我们用来求平均深度的采样面积,也就是离光源越近,我们所采样的范围就越大。
那么Light到ShadowMap的距离是一个怎样的概念?这里我们认为ShadowMap在近平面的位置,那距离就是近平面的深度了。
最后我们来总结一下PCSS的计算步骤:
计算出范围内遮挡物的平均深度
计算出Penumbra作为阴影虚实程度系数
进行PCF(诶?怎么是PCF?前面提到过,PCF可以用来实现软阴影,其采样范围越大,阴影越“软”,而Penumbra决定了某个阴影点的虚实程度,所以我们把PCF采样范围乘以Penumbra,即可达到近实远虚的软阴影效果)
下面来看具体实现
1 2 3 4 5 #define NEAR_PLANE 0.01 #define LIGHT_WORLD_SIZE 5. #define LIGHT_SIZE_UV LIGHT_WORLD_SIZE / FRUSTUM_SIZE
我们先定义一下需要的数据,其中NEAR_PLANE为光源所用透视矩阵的近平面数据,而LIGHT_WORLD_SIZE是我们自行设定,可根据效果调节的光源在世界空间的大小,而LIGHT_SIZE_UV则是光源在ShadowMap上的UV单位大小,可通过光源在世界空间的大小除以FRUSTUM_SIZE获得,FRUSTUM_SIZE我们在前面已经定义过。(注意这里设定ShadowMap和FRUSTUM都是正方形)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 float findBlocker (sampler2D shadowMap, vec2 uv, float zReceiver) { int blockerNum = 0 ; float blockDepth = 0. ; float posZFromLight = vPositionFromLight.z; float searchRadius = LIGHT_SIZE_UV * (posZFromLight - NEAR_PLANE) / posZFromLight; poissonDiskSamples(uv); for (int i = 0 ; i < NUM_SAMPLES; i++){ float shadowDepth = unpack(texture2D(shadowMap, uv + poissonDisk[i] * searchRadius)); if (zReceiver > shadowDepth){ blockerNum++; blockDepth += shadowDepth; } } if (blockerNum == 0 ) return -1. ; else return blockDepth / float (blockerNum); }
先实现查找一个点在某范围内的遮挡物平均深度函数findBlocker。根据上文所说的确定范围的规则,用相似三角形求出在ShadowMap上的查找范围(这里posZFromLight和NEAR_PLANE是光源空间下的单位,而LIGHT_SIZE_UV和searchRadius是ShadowMap的UV单位),然后用这个范围进行一个类似于PCF的计算,只不过PCF是计算出与深度比较结果的平均值,而这里则是计算遮挡物的深度平均值。
另外注意需要特殊处理范围内无遮挡物的情况,这里我们返回-1来表示这种情况,因为有遮挡物的情况下不会出现-1这个值,当然用别的方式表达也可以。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 float PCSS (sampler2D shadowMap, vec4 coords, float biasC) { float zReceiver = coords.z; float avgBlockerDepth = findBlocker(shadowMap, coords.xy, zReceiver); if (avgBlockerDepth < -EPS) return 1.0 ; float penumbra = (zReceiver - avgBlockerDepth) * LIGHT_SIZE_UV / avgBlockerDepth; float filterRadiusUV = penumbra; return PCF(shadowMap, coords, biasC, filterRadiusUV); }
然后再把PCSS的实现补充完整
调用findBlocker取得结果,注意当结果是负数的时候表示范围内无遮挡物,则返回的visibility值为1。
根据上文所说的penumbra的计算方法,计算出penumbra。
直接使用penumbra作为PCF的采样范围进行PCF,这里复用之前的PCF的实现,注意传入bias参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 void main (void ) { float visibility = 1. ; float pcfBiasC = .2 ; visibility = PCSS(uShadowMap, vec4(shadowCoord, 1.0 ), pcfBiasC); vec3 phongColor = blinnPhong(); gl_FragColor = vec4(phongColor * visibility, 1.0 ); }
当然别忘了在main函数里改成调用PCSS。
最终结果如下:
可以看到能得到很不错的软阴影效果。
关于PCSS,我们可以参考Nvidia的实现Whitepaper - Integrating Realistic Soft Shadows into Your Game Engine
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 float PCSS ( Texture2D shadowMapTex, float4 coords ) { float2 uv = coords.xy; float zReceiver = coords.z; float avgBlockerDepth = 0 ; float numBlockers = 0 ; FindBlocker( avgBlockerDepth, numBlockers, uv, zReceiver ); if ( numBlockers < 1 ) return 1.0f ; float penumbraRatio = PenumbraSize(zReceiver, avgBlockerDepth); float filterRadiusUV = penumbraRatio * LIGHT_SIZE_UV * NEAR_PLANE / coords.z; return PCF_Filter( uv, zReceiver, filterRadiusUV ); }
在Nvidia的这个白皮书中PCSS实现是这样的。
本文与Nvidia在实现基本上一样,只在penumbra的计算上有一处区别,也是202课堂上没有提到的内容。
1 2 3 4 5 float penumbra = (zReceiver - avgBlockerDepth) * LIGHT_SIZE_UV / avgBlockerDepth;float penumbra = (zReceiver - avgBlockerDepth) * (LIGHT_SIZE_UV * NEAR_PLANE / coords.z) / avgBlockerDepth;
Nvidia的实现对LIGHT_SIZE_UV
项再做了* NEAR_PLANE / coords.z
计算,然而关于这个coords.z
,结合上面的注释Assumed to be eye-space z in this code
,以及在FindBlocker和PCSS中使用时的意义来看,我没搞明白这个coords.z
是什么空间下的值,并在尝试与Nvidia实现保持一致时,并没有得出正常的结果。希望有了解的同学能解答一下。
Bonus部分:多光源 ShadowMap 和动态物体
先明确一下我们想要实现的效果:
实现多光源阴影,并且创建两个光源围绕场景原点旋转
实现场景中的人物模型原地旋转
模型旋转
实际上作业框架并没有完善旋转所需要的逻辑,需要我们自行补充,这部分属于GMAES101中的基础部分,不过多说明,直接贴代码修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function setTransform (t_x, t_y, t_z, r_x, r_y, r_z, s_x, s_y, s_z ) { return { modelTransX : t_x, modelTransY : t_y, modelTransZ : t_z, modelRotateX : r_x, modelRotateY : r_y, modelRotateZ : r_z, modelScaleX : s_x, modelScaleY : s_y, modelScaleZ : s_z, }; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 class DirectionalLight { constructor (lightIntensity, lightColor, lightPos, focalPoint, lightUp, hasShadowMap, gl ) { this .mesh = Mesh.cube(setTransform(0 , 0 , 0 , 0 , 0 , 0 , 0.5 , 0.5 , 0.5 , 0 )); this .mat = new EmissiveMaterial(lightIntensity, lightColor); this .lightPos = lightPos; this .focalPoint = focalPoint; this .lightUp = lightUp this .hasShadowMap = hasShadowMap; this .fbo = new FBO(gl); if (!this .fbo) { console .log("无法设置帧缓冲区对象" ); return ; } } CalcLightMVP (translate, rotate, scale ) { let lightMVP = mat4.create(); let modelMatrix = mat4.create(); let viewMatrix = mat4.create(); let projectionMatrix = mat4.create(); mat4.translate(modelMatrix, modelMatrix, translate) mat4.rotateX(modelMatrix, modelMatrix, rotate[0 ]) mat4.rotateY(modelMatrix, modelMatrix, rotate[1 ]) mat4.rotateZ(modelMatrix, modelMatrix, rotate[2 ]) mat4.scale(modelMatrix, modelMatrix, scale) mat4.lookAt(viewMatrix, this .lightPos, this .focalPoint, this .lightUp) var r = 200 ; var l = -r; var t = 200 ; var b = -t; var n = 0.01 ; var f = 500 ; mat4.ortho(projectionMatrix, l, r, b, t, n, f); mat4.multiply(lightMVP, projectionMatrix, viewMatrix); mat4.multiply(lightMVP, lightMVP, modelMatrix); return lightMVP; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class PhongMaterial extends Material { constructor (color, specular, light, translate, rotate, scale, vertexShader, fragmentShader ) { let lightMVP = light.CalcLightMVP(translate, rotate, scale); let lightIntensity = light.mat.GetIntensity(); super ({ 'uSampler' : { type : 'texture' , value : color }, 'uKs' : { type : '3fv' , value : specular }, 'uLightIntensity' : { type : '3fv' , value : lightIntensity }, 'uShadowMap' : { type : 'texture' , value : light.fbo }, 'uLightMVP' : { type : 'matrix4fv' , value : lightMVP }, }, [], vertexShader, fragmentShader); } } async function buildPhongMaterial (color, specular, light, translate, rotate, scale, vertexPath, fragmentPath ) { let vertexShader = await getShaderString(vertexPath); let fragmentShader = await getShaderString(fragmentPath); return new PhongMaterial(color, specular, light, translate, rotate, scale, vertexShader, fragmentShader); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class ShadowMaterial extends Material { constructor (light, translate, rotate, scale, vertexShader, fragmentShader ) { let lightMVP = light.CalcLightMVP(translate, rotate, scale); super ({ 'uLightMVP' : { type : 'matrix4fv' , value : lightMVP } }, [], vertexShader, fragmentShader, light.fbo); } } async function buildShadowMaterial (light, translate, rotate, scale, vertexPath, fragmentPath ) { let vertexShader = await getShaderString(vertexPath); let fragmentShader = await getShaderString(fragmentPath); return new ShadowMaterial(light, translate, rotate, scale, vertexShader, fragmentShader); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class TRSTransform { constructor (translate = [0 , 0 , 0 ], rotate = [0 , 0 , 0 ], scale = [1 , 1 , 1 ] ) { this .translate = translate; this .rotate = rotate; this .scale = scale; } } const modelTranslation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ]; const modelRatation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ]; const modelScale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ]; let meshTrans = new TRSTransform(modelTranslation, modelRatation, modelScale); this .transform = meshTrans;
1 2 3 4 5 6 7 8 9 10 11 12 13 mat4.identity(modelMatrix); mat4.translate(modelMatrix, modelMatrix, this .mesh.transform.translate); mat4.rotateX(modelMatrix, modelMatrix, this .mesh.transform.rotate[0 ]) mat4.rotateY(modelMatrix, modelMatrix, this .mesh.transform.rotate[1 ]) mat4.rotateZ(modelMatrix, modelMatrix, this .mesh.transform.rotate[2 ]) mat4.scale(modelMatrix, modelMatrix, this .mesh.transform.scale);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 let material, shadowMaterial;let Translation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ];let Rotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ];let Scale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ];let light = renderer.lights[0 ].entity;switch (objMaterial) { case 'PhongMaterial' : material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Rotation, Scale, "./src/shaders/phongShader/phongVertex.glsl" , "./src/shaders/phongShader/phongFragment.glsl" ); shadowMaterial = buildShadowMaterial(light, Translation, Rotation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl" , "./src/shaders/shadowShader/shadowFragment.glsl" ); break ; }
以上是要在模型变换中要支持旋转所需要修改的地方
1 2 3 4 5 let floorTransform = setTransform(0 , 0 , -30 , 0 , 0 , 0 , 4 , 4 , 4 );let obj1Transform = setTransform(0 , 0 , 0 , 0 , 0 , 0 , 20 , 20 , 20 );let obj2Transform = setTransform(40 , 0 , -40 , 0 , 0 , 0 , 10 , 10 , 10 );
现在已经有了旋转的支持,那么我们回头把setTransform的rotation参数补上。
这里我们把中间三个旋转参数初始化为0,因为我们要支持的是持续旋转,那么场景中的物体就需要感觉到时间的流逝,为了实现这一点我们需要在引擎mainloop中获取deltatime:
1 2 3 4 5 6 7 8 9 10 11 12 13 let prevTime = 0 ;function mainLoop (now ) { cameraControls.update(); let deltaime = (now - prevTime) / 1000 ; renderer.render(now, deltaime); requestAnimationFrame(mainLoop); prevTime = now; }
然后我们在WebGLRenderer的render函数中接收时间参数即可。
1 2 3 4 5 6 7 render (time, deltaime ) {}
1 2 3 4 5 6 function degrees2Radians (degrees ) { return 3.1415927 / 180 * degrees; }
注意要控制旋转的时接受的是弧度参数,为了更直观我们希望使用角度,所以先engine.js
里添加一个角度转弧度的函数实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 gl.clearColor(0.0 , 0.0 , 0.0 , 1.0 ); gl.clearDepth(1.0 ); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); console .assert(this .lights.length != 0 , "No light" );console .assert(this .lights.length == 1 , "Multiple lights" );for (let i = 0 ; i < this .meshes.length; i++) { if (this .meshes[i].mesh.count > 10 ) { this .meshes[i].mesh.transform.rotate[1 ] = this .meshes[i].mesh.transform.rotate[1 ] + degrees2Radians(10 ) * deltaime; } }
一切就绪,我们现在可以添加二次元妹妹模型旋转的实现了!实现很简单,因为我们是做绕Y轴的旋转,所以每帧把rotate的第二个参数(索引1)加上要旋转的值就OK了,注意乘上deltatime,如上面实现就是每秒绕Y轴旋转5°。
但有个问题,我只希望妹妹旋转,不希望地面旋转,然鹅这里无法直接判断这个mesh是个什么模型,如果为了做特别区分,去支持加载模型时加入tag等数据又显得很麻烦,为了加入旋转,我们已经在很多地方插入代码了。
怎么办呢?困りますね ,后来我灵只因一动决定通过mesh的顶点数来筛选,地面的顶点数是6,我们只对mesh.count > 10
的模型进行旋转即可。
这里另外补充一个小说明,WebGLRenderer里包含了meshes和shadowMeshes两个数组字段,我们需要对他们都进行渲染,而我们这里只修改了meshes的旋转信息,是因为meshes和shadowMeshes并不是Mesh本身,而是两组MeshRender,这里命名不是很准确,MeshRender里才包含了真正的Mesh实例,而这两组MeshRender其实是指向同一组Mesh,所以我们只需要对其中一组进行修改即可。
然后我们来看看效果:
可以看到202娘已经在旋转了,但是阴影并没有跟着旋转,这是怎么回事呢。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 for (let l = 0 ; l < this .lights.length; l++) { gl.bindFramebuffer(gl.FRAMEBUFFER, this .lights[l].entity.fbo); gl.clearColor(1.0 , 1.0 , 1.0 , 1.0 ); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); this .lights[l].meshRender.mesh.transform.translate = this .lights[l].entity.lightPos; this .lights[l].meshRender.draw(this .camera); if (this .lights[l].entity.hasShadowMap == true ) { for (let i = 0 ; i < this .shadowMeshes.length; i++) { this .gl.useProgram(this .shadowMeshes[i].shader.program.glShaderProgram); let translation = this .shadowMeshes[i].mesh.transform.translate; let rotation = this .shadowMeshes[i].mesh.transform.rotate; let scale = this .shadowMeshes[i].mesh.transform.scale; let lightMVP = this .lights[l].entity.CalcLightMVP(translation, rotation, scale); this .shadowMeshes[i].material.uniforms.uLightMVP = { type : 'matrix4fv' , value : lightMVP }; this .shadowMeshes[i].draw(this .camera); } } for (let i = 0 ; i < this .meshes.length; i++) { this .gl.useProgram(this .meshes[i].shader.program.glShaderProgram); let translation = this .meshes[i].mesh.transform.translate; let rotation = this .meshes[i].mesh.transform.rotate; let scale = this .meshes[i].mesh.transform.scale; let lightMVP = this .lights[l].entity.CalcLightMVP(translation, rotation, scale); this .meshes[i].material.uniforms.uLightMVP = { type : 'matrix4fv' , value : lightMVP }; this .meshes[i].material.uniforms.uLightPos = { type : '3fv' , value : this .lights[l].entity.lightPos }; this .meshes[i].draw(this .camera); } }
如上面代码,这里我们来处理几个问题:
虽然Shadow Pass是每帧绘制的,但我们没有更新lightMVP矩阵,导致物体变动并没有反映在ShadowMap上,这里我们需要在两个Pass中都每帧更新一下shader中的参数uLightMVP,即可解决阴影不更新的问题。
1 2 3 4 5 gl.bindFramebuffer(gl.FRAMEBUFFER, this .lights[l].entity.fbo); gl.clearColor(1.0 , 1.0 , 1.0 , 1.0 ); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
另外我们还包含了这三行图形API的调用。
第一行是表示绑定当前灯光的framebuffer,后续操作都对绑定的framebuffer生效。
第二行是为了解决上图出现的,在地面边缘有黑影的问题,因为原本ShadowMap默认为黑色,地板外的地方因为没采样到,所以值为默认值0,而这在我们的采样中会被认为visibility为0,所以会产生阴影,这里我们对ShadowMap进行设置,当执行clear操作时,默认以白色(值为1)填充即可解决。
第三行很重要,首先第三行才是真正执行清除的操作,让第二行代码生效,第二行只是一个设置,另外这也是清除掉上一帧ShadowMap数据的操作,不然阴影动起来时会发现阴影会叠加。
动态多光源
要支持多光源同样需要改不少地方,不知道是框架没考虑作业的提高部分,还是故意为之,GAMES这两门渲染课程一直都是注重图形学本身,轻工程部分,尽量提供完善框架,大家只需要填空实现即可,但这个作业的提高部分目前看来需要改的地方也太多了点。
由于在WebGLRenderer中只用了两个数组区分了Shadow Pass的MeshRender和Camera Pass的MeshRender,在多光源情况下,我们把不同光源对应的MeshRender也会添加到其中,我们需要做一个区分,避免在每一轮的光源渲染中都把所有光源包含的MeshRender都Draw了一次,但框架现有代码似乎没有太好的方式可以区分,我决定给Material类添加一个lightIndex字段表示对应光源,修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 constructor (uniforms, attribs, vsSrc, fsSrc, frameBuffer, lightIndex ) { this .uniforms = uniforms; this .attribs = attribs; this .#vsSrc = vsSrc; this .#fsSrc = fsSrc; this .#flatten_uniforms = ['uViewMatrix' ,'uModelMatrix' , 'uProjectionMatrix' , 'uCameraPos' , 'uLightPos' ]; for (let k in uniforms) { this .#flatten_uniforms.push(k); } this .#flatten_attribs = attribs; this .frameBuffer = frameBuffer; this .lightIndex = lightIndex; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class ShadowMaterial extends Material { constructor (light, translate, rotate, scale, lightIndex, vertexShader, fragmentShader ) { let lightMVP = light.CalcLightMVP(translate, rotate, scale); super ({ 'uLightMVP' : { type : 'matrix4fv' , value : lightMVP } }, [], vertexShader, fragmentShader, light.fbo, lightIndex); } } async function buildShadowMaterial (light, translate, rotate, scale, lightIndex, vertexPath, fragmentPath ) { let vertexShader = await getShaderString(vertexPath); let fragmentShader = await getShaderString(fragmentPath); return new ShadowMaterial(light, translate, rotate, scale, lightIndex, vertexShader, fragmentShader); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class PhongMaterial extends Material { constructor (color, specular, light, translate, rotate, scale, lightIndex, vertexShader, fragmentShader ) { let lightMVP = light.CalcLightMVP(translate, rotate, scale); let lightIntensity = light.mat.GetIntensity(); super ({ 'uSampler' : { type : 'texture' , value : color }, 'uKs' : { type : '3fv' , value : specular }, 'uLightIntensity' : { type : '3fv' , value : lightIntensity }, 'uShadowMap' : { type : 'texture' , value : light.fbo }, 'uLightMVP' : { type : 'matrix4fv' , value : lightMVP }, }, [], vertexShader, fragmentShader, null , lightIndex); } } async function buildPhongMaterial (color, specular, light, translate, rotate, scale, lightIndex, vertexPath, fragmentPath ) { let vertexShader = await getShaderString(vertexPath); let fragmentShader = await getShaderString(fragmentPath); return new PhongMaterial(color, specular, light, translate, rotate, scale, lightIndex, vertexShader, fragmentShader); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 let material, shadowMaterial;let Translation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ];let Rotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ];let Scale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ];for (let i = 0 ; i < renderer.lights.length; i++){ let light = renderer.lights[i].entity; switch (objMaterial) { case 'PhongMaterial' : material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Rotation, Scale, i, "./src/shaders/phongShader/phongVertex.glsl" , "./src/shaders/phongShader/phongFragment.glsl" ); shadowMaterial = buildShadowMaterial(light, Translation, Rotation, Scale, i, "./src/shaders/shadowShader/shadowVertex.glsl" , "./src/shaders/shadowShader/shadowFragment.glsl" ); break ; } material.then((data ) => { let meshRender = new MeshRender(renderer.gl, mesh, data); renderer.addMeshRender(meshRender); }); shadowMaterial.then((data ) => { let shadowMeshRender = new MeshRender(renderer.gl, mesh, data); renderer.addShadowMeshRender(shadowMeshRender); }); }
以上是支持多光源的代码支撑,接下来就是添加光源了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let lightPos1 = [0 , 80 , 80 ];let focalPoint = [0 , 0 , 0 ];let lightUp = [0 , 1 , 0 ]const directionLight = new DirectionalLight(2500 , [1 , 1 , 1 ], lightPos1, focalPoint, lightUp, true , renderer.gl);renderer.addLight(directionLight); let lightPos2 = [90 , 90 , 0 ];const directionLight2 = new DirectionalLight(2500 , [1 , 1 , 1 ], lightPos2, focalPoint, lightUp, true , renderer.gl);renderer.addLight(directionLight2);
添加第二个光源,位置可以自由调整,但注意位置太远会脱离我们定义的渲染ShadowMap的视锥体,那样就无法绘制阴影了,为了防止画面太亮,我们也调整一下两个光源的亮度。
1 2 3 4 console .assert(this .lights.length != 0 , "No light" );
然后我们注释掉WebGLRenderer中一行光源数量检测(不知道作业框架加这个检测做什么)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 for (let l = 0 ; l < this .lights.length; l++) { let lightRotateSpped = [10 , 80 ] let lightPos = this .lights[l].entity.lightPos; lightPos = vec3.rotateY(lightPos, lightPos, this .lights[l].entity.focalPoint, degrees2Radians(lightRotateSpped[l]) * deltaime); this .lights[l].entity.lightPos = lightPos; this .lights[l].meshRender.mesh.transform.translate = lightPos; this .lights[l].meshRender.draw(this .camera); if (this .lights[l].entity.hasShadowMap == true ) { for (let i = 0 ; i < this .shadowMeshes.length; i++) { if (this .shadowMeshes[i].material.lightIndex != l) continue ; this .shadowMeshes[i].draw(this .camera); } } if (l != 0 ) { gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); } for (let i = 0 ; i < this .meshes.length; i++) { if (this .meshes[i].material.lightIndex != l) continue ; this .meshes[i].draw(this .camera); } gl.disable(gl.BLEND); }
最后添加动态多光源的核心实现,总结一下上面代码改动:
实现光源围绕原点进行Y轴旋转,注意这里的旋转并不是模型变换里的旋转,这里旋转的结果是位置发生变化,最后真正产生了“旋转”的,是光照方向的朝向,其对应光源的观察变换。
在Shadow Pass和Camera Pass中都判断一下当前MeshRender的材质的lightIndex与当前渲染中的光源的Index是否一致,不一致的跳过,不然会把不属于当前光源的MeshRender全部渲染一遍。
由于是多光源绘制,我们需要把第二个及以后的光源的Camera Pass渲染结果都叠加到第一个光源的Camera Pass渲染结果上,需要开启混合,并以one one模式叠加,否则只能看到最后一个光源的渲染结果。
大功告成!这就是文章开头的最终效果: