查看原文
其他

还在用贴片做阴影?Cocos Creator 带你通过一维纹理绘制 2D 实时阴影

好巧啊c COCOS 2022-06-10

Cocos 的布道师正在论坛陆续更新 Cocos Creator 3.x 游戏常用效果技术实现 Demo,Demo 将随着 Cocos Creator 更新迭代,确保在最新版本中可以运行。点击文末【阅读原文】直达论坛专贴,各位开发者有什么想要实现的效果,欢迎留言告诉我们。



阴影对于提升场景的真实感和质感有至关重要的作用。Cocos Creator 支持两种 3D 阴影的实现方式:Planar 和 ShadowMap。


  • Planer:通过将阴影生成体投射到阴影接受体的平面来制作阴影;

  • ShadowMap:通过对在光源位置放置相机拍摄 ShadowMap(阴影贴图)来实现的。


这两种方式不仅操作简单,而且实现效果很好。但是对于 2D 游戏,就不那么适用了。很多 2D 游戏的阴影都是通过贴片来实现的。但今天,我们要基于 Cocos Creator 3.3.2 来介绍另一种实现方案:通过一维纹理生成 2D 实时阴影。


Demo 效果预览


Demo 实现了基于 2D 点光源的动态阴影,且对于 Demo 内所有不透明的像素都做了阴影处理,光源和阴影的边缘也做了柔和处理,符合我们对阴影的观察。


接下来我将为大家拆解实现步骤、并深入了解一下实现原理,开发者可选择对应的部分观看。


章节目录

PART 1. 使用方法

PART 2. 实现原理

PART 3. Shader 渲染解析

PART 4. 性能影响

PART 5. 资源链接


PART 1

使用方法

1、创建层级:1dmap



2、创建 render texture:rt_1dmap 和 rt_picture



3、创建3个相机:



  • CameraPicture:使用 rt_picture 作 TargetTexture


  • CameraShadow:使用 rt_1dmap 作为 TargetTexture


  • Camera:作为 Cocos Creator 创建 UI 时的默认相机


4、创建场景


创建渲染到阴影贴图的场景 sprite:shadow_1dmap


注意:其 Layer 必须是 1dmap, 这个作用是用于渲染场景到阴影贴图,具体的原理我们会在后文解释。



制作一个全屏的空白 sprite 并使用 rt_shadow 作为 sprite frame。



创建正常的场景, 复制 shadow_1dmap 并重命名为 shadow_normal,注意修改其层级为 UI_ID。



创建光源节点(Sprite) Light :为 Light 添加 LightShadow 节点并将对应的信息进行关联。



Demo 内剩下就是给光源增加了一个动画,这个大家可以去参考下 Cocos 的动画制作流程,这里就不赘述了。



如果看到这里,你还没有迷糊的话,那么就可以接下去看看它的实现原理。


PART 2

实现原理

算法流程


首先,正常进行一次场景的绘制,并将场景绘制到 rt_picture 上。



然后,遍历历整个贴图内找到离光源位置最近的点,记录下这个写入到 1d_map 上。这里通过比较 alpha 值来判断是否需要写入到阴影纹理内。



然后,使用 rt_picture 作为纹理绘制全屏阴影 shadow_render。渲染时去 rt_picture 内采样,判定当前的位置到光源的距离是否小于 rt_picture 内记录的距离。如果是,绘制光照效果,反之绘制阴影效果。



最后,正常绘制场景覆盖在阴影 sprite 上面。



总体绘制流程是这样的:



学习这种实现方式,需要了解一些前置的知识。接下来我们简单了解一下极坐标、render target 和 render texture、UV space、阴影和光照的原理这四个方面的内容,对这一部分比较熟悉的小伙伴们可以直接跳过。


前置知识


极坐标和笛卡尔坐标



极坐标是通过半径和夹角来对位置来进行描述。对于平面坐标系内,任意一点(X,Y)都可以用他们的向量长度 R 和夹角 θ 来进行描述,这样我们的坐标系就被转换到一个半径为 R 的圆内。


使用极坐标可以方便的描述点光源的性质:


  1. 从某个点超其他所有方向发射出光源

  2. 根据距离的变化,光线的强度会衰减

  3. 无法被光线照射的地方产生阴影


笛卡尔到极坐标:


r = \sqrt(x^2+y^2)

θ = asin(y/r)


极坐标到笛卡尔:


x = r*cos(θ)

y = r*sin(θ)


RenderTarget


正常情况下,我们进行渲染,是会将输出的结果直接显示在屏幕上。现代显卡允许我们将要输出要屏幕上的内容,输出到一张纹理上,这种纹理被称之为 RT(render texture) 或者 RTT(render target texture),在使用时和普通纹理没有区别。


在图形学里面,render texture 可以像普通纹理一样被采样,因此我们在制作很多特效的时候都会用到,比如常见的各种 PostEffect,都是使用 render texture 制作的。

制作方式就是新增一个相机,让相机只绘制你需要的 Layer, 之后给相机的 RenderTarget 赋予一张 RT。


使用纹理来保存数据


纹理有不同的格式,比如 A8R8G8B8,这是一个拥有32个 bit 的纹理,每8个 bit 表示一个颜色分量。如果加上 Float_A8R8G8B8,那么则变成了一个 32 位浮点数的纹理。


在不需要那么高精度的情况下,使用 32bit 来存储一个浮点数太过浪费了,完全可以使用1个通道8个 bit 来存储1个浮点数,那么4个通道就可以存储4个浮点数。这时,就可以将不超过[0,1]这个区间内的数值存储在纹理内。


如果数值超过这个区间,需要对其做一些简单的数学变换。


三角函数的值范围在[-1, 1]这个区间内,那么通过简单的数学运算,将其变换到[0,1]这个范围内,比如:

 [-1,1] === > [-0.5, 0.5] == > [0, 1]


在图形学里面,纹理可以储存任何你想要的数据,在 Cocos Creator 的动画系统中,骨骼数据也存在纹理中。


UV space


UV space 就是通过上述的原理,将世界坐标转换到 UV 坐标系。


这种转换的意义就是因为纹理内只能存放[0,1]的数值。对于超出这个范围的,需要对坐标做一些处理:

// rectangular to polar filter        
vec2 norm = vec2(uv0.x, y/resolution.y) * 2.0 - 1.0//转到极坐标 
这里 y/resolution.y 就将Y值改变到[01]这个区间内。
乘以 2 在减 1 之后会转换到[-11]这个区间内。



ShadowMap 的原理


「无光之处皆为暗影」。所谓阴影,就是光线照射不到的地方。也就是说,对于 2D 光源来讲,我们只要在其半径上找到一个点,途径这个点的光线,在这个点终止,超出这个点范围外的则是阴影。


Demo 中使用了 alpha 值大于某个值来标记为不透明物体,在 gen_1dmap.effect 记录中:

// the current distance is how far from the top we've come  
float dst = y/resolution.y; 

// if we've hit an opaque fragment (occluder), then get new distance
// if the new distance is below the current, then we'll use that for our ray
float caster = data.a;  
if (caster > THRESHOLD) {  // alpha大于这个值表示有物体阻挡光线
  distance = min(distance, dst); //写入一维纹理 
}


找到这个点以后,我们记录下他和光源中心的半径,那么在绘制时,超出该半径的地方绘制阴影,在半径内,绘制光源。在 render_1dmap.effect 取距离:

//sample from the 1D distance map
float shadow_sample(vec2 coord, float r) {
  return step(r, texture(cc_spriteTexture, coord).r);
}


这样就解决了 2D 光源的问题。为了达到这个目的,我们需要先将场景绘制一次,得到的 rendertarget 内,alpha 值高于设定值的片元,则认为其会产生阴影,之后将这个距离,写入到纹理里面,作为最终的阴影贴图。完成所有的片元操作后,开始绘制阴影阶段。


衰减


在现实中可以观测到一些光源和阴影的特性:光线在随着距离延长,能量会衰减,体现出的结果就是亮度变暗,而在光线和阴影交替的地方,就会产生模糊的样子。


在 Demo 中,采用了高斯混合的方式来模拟衰减:

//now we use a simple gaussian blur
float sum = 0.0;

sum += shadow_sample(vec2(tc.x - 4.0*blur, tc.y), r) * 0.05;
sum += shadow_sample(vec2(tc.x - 3.0*blur, tc.y), r) * 0.09;
sum += shadow_sample(vec2(tc.x - 2.0*blur, tc.y), r) * 0.12;
sum += shadow_sample(vec2(tc.x - 1.0*blur, tc.y), r) * 0.15;

sum += center * 0.16;

sum += shadow_sample(vec2(tc.x + 1.0*blur, tc.y), r) * 0.15;
sum += shadow_sample(vec2(tc.x + 2.0*blur, tc.y), r) * 0.12;
sum += shadow_sample(vec2(tc.x + 3.0*blur, tc.y), r) * 0.09;
sum += shadow_sample(vec2(tc.x + 4.0*blur, tc.y), r) * 0.05;

//1.0 -> in light, 0.0 -> in shadow
 float lit = mix(center, sum, 1.0);


高斯混合的实现就是将纹理坐标附近的左右的点的采样值取一定比例后做一个加权。这个方法在计算机图形学里的学名叫:卷积



这样的阴影边缘不会有陡然的过度,能模拟出和现实类似的效果。


PART 3

Shader 渲染解析

整个渲染算法我们之前已经分析过了,现在我们逐步分析下这些 Shader 的关键代码。


捕获阴影贴图


第一步需要对场景进行一次正常的渲染,获取到需要进行阴影处理的像素。


lightshadow.ts 设置光源的位置和屏幕分辨率:

let uitrans = this.picture_1dmap_gen.node.getComponents(UITransform)[0]; 
let lpos = uitrans.convertToNodeSpaceAR(wpos);
let lpos_offset = new Vec2(lpos.x / uitrans.width, lpos.y / uitrans.height); 
//设置光源的位置
this.picture_1dmap_gen.customMaterial.setProperty('light_position', lpos_offset);
//设置分辨率
this.picture_1dmap_gen.customMaterial.setProperty('resolution'new Vec2(_size.width, _size.height)); 


之后在 gen_1dmap.effect 内渲染正常场景时,记录光线的最小半径:

float caster = data.a;  // alpha大于这个值表示有 
if (caster > THRESHOLD) { 
  distance = min(distance, dst); //写入一维纹理 
}


渲染阴影


在代表阴影的 sprite 上使用 render_1dmap.effect 绘制阴影和光照贴图。


从阴影纹理内采样:

//sample from the 1D distance map
float shadow_sample(vec2 coord, float r) {
  return step(r, texture(cc_spriteTexture, coord).r);
}

step(edge, x) 这个方法的含义是:当 x < edge 则返回0,反之返回1。


绘制光照:

通过插值center(阴影纹理采样的结果)和sum(高斯混合的结果)得到当前点像素的
 //1.0 -> in light, 0.0 -> in shadow
float lit = mix(center, sum, 1.0);

和光源的距离计算插值计算alpha,离的光源越近,alpha越少
return vec4(vec3(shadowColor.r, shadowColor.g, shadowColor.b), lit * smoothstep(1.00.0, r));


渲染正常的场景


在渲染阴影完成以后,我们只需要再重新绘制一次当前的场景,将其覆盖在阴影图层上面则可以完成最终的效果。下面是所有的 RT 以及最终渲染的结果:



PART 4

性能影响

RT 必定会增加 DrawCall,因此需要将场景绘制多次。


在 Demo 中我们的场景绘制了两次,因此 DC=场景 DC*2


Shader 内做了基于分辨率的循环, 这块对性能也是有影响的:

分辨率越高,那么循环次数就越多,性能损耗就愈大
for (float y = 0.0; y < 2048.0; y += 1.0
    if (y > resolution.y) break


高斯模糊需要纹理进行多次采样,采样次数越多,性能越低:

sum += shadow_sample(vec2(tc.x - 4.0*blur, tc.y), r) * 0.05;


PART 5

资源链接

点击【阅读原文】下载 Demo

https://github.com/cocos-creator/CococsCreator-public-technology-solutions/tree/main/demo/Creator3.3.2_2D_ShadowMap


论坛集中贴

https://forum.cocos.org/t/topic/124637


参考文章

Cocos Creator render texture

https://docs.cocos.com/creator3d/manual/zh/asset/render-texture.html?h=rendertexture

*部分图源于网络,如有侵权请与我们联系


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存