【分享】自定义渲染应用——图片遮罩合批

背景

不少同学在用shader实现某个特效后都会遇到这样一个问题,在测试的时候效果完全正确,一集成到项目中后纹理就发生错位。这种情况大多是由于shader使用的纹理参与了合图导致uv发生了变化。


简单的处理方法是去掉纹理的packable属性,使其不参与合图。但是这样会带来一个问题: 不合图意味着渲染时无法参与合批,如果有大量节点用到了这个shader,那么drawcall就会较高。


经过一段时间的论坛灌水,发现有不少关于 图片遮罩的话题,所以本文就以批量图片遮罩为例介绍合批处理方法。

先来看两张效果图,一张是用 shader画圆做遮罩,这里的遮罩效果可以任意替换为其他shader效果;另一张是 **自定义纹理做遮罩,**合批渲染后均只占一个draw call。


Demo快速传送门
本文的实现基于这篇分享所介绍的 自定义顶点格式,想要了解实现原理的同学可以去回顾一下。

纹理uv坐标

注:为了简单起见,本文不对图片的 透明裁剪 和合图时的 旋转 进行讨论。实际的代码里会做相应细节处理
所有描述都以图片不做透明裁剪以及不旋转合图讨论

ccc中纹理uv坐标 以左上角为原点cc.SpriteFrame 的uv属性中,uv坐标按 左下、右下、左上、右上顺序排列,其值的含义是这个 cc.SpriteFrame 的四个顶点在 cc.Texture 纹理空间中的坐标。



shader画圆遮罩

如果要用shader对图片做一个圆形遮罩,通常要计算像素距离图片中心的距离,这里需要获取图片中心的uv坐标。
但是实际上在合图之后,基于图片中心uv的计算很难保证画出一个圆,即使当前的 cc.SpriteFrame 长宽是相同的,在合图后的大纹理内 并不能保证相对的长宽比例相同
个人的推荐做法是将 cc.SpriteFrameuv重新映射到[0,1]区间,方便shader处理。

  // 将[a, b]区间映射到[0, 1]区间
  // t是[a, b]区间内的值
  // 函数返回t被映射后的值
  float Remap01(float a, float b, float t) {
    return (t-a) / (b-a);
  }

  void main () {
    vec2 uv = v_uv0.xy;
    vec4 col = texture(texture, uv);

    // v_xrange.xy分别表示子纹理的x轴左右边缘坐标
    // v_yrange.xy分别表示子纹理的y轴上下边缘坐标
    // uv的xy轴分别映射到[0, 1]区间,之后shader的写法即可按照未合图前的方式处理
    uv.x = Remap01(v_xrange.x, v_xrange.y, uv.x);
    uv.y = Remap01(v_yrange.x, v_yrange.y, uv.y);

    // 画圆形遮罩,以(0.5, 0.5)为圆心
    float d = distance(uv, vec2(0.5, 0.5));
    float r = 0.5;
    float mask = smoothstep(r + 0.01, r - 0.01, d);
    col.a = mask;
 
    gl_FragColor = col;
  }

上面这段片元着色器中用到了 v_xrangev_yrange 两个变量,需要以参数形式输入。
为了 不打断合批,使用这篇分享所介绍的 自定义顶点格式方式传入。

这种映射不仅能处理画圆的场景,任何需要计算相对位置、距离的shader都可以用这种方式处理合图后的uv
实际的Demo代码中使用的是Remap01优化后的变种,但是原理是完全一致的


纹理遮罩

纹理遮罩合批需要保证底图和遮罩都参与合图。
可以选择 底图合一张大图,遮罩合另一张大图;也可以选择 底图、遮罩都合到一起
遮罩的uv也是通过顶点属性传入shader。

  in vec2 v_uv0;              // 底图uv
  uniform sampler2D texture;  // 底图纹理
  in vec2 v_mask_uv;          // 遮罩图uv,通过顶点属性传入
  uniform sampler2D mask;     // 遮罩图纹理,通过材质属性传入
  uniform UARGS {
    float enableMask;         // 遮罩图开关控制,通过材质属性传入
  };

  void main () {
    // 对底图采样
    vec4 col = texture(texture, v_uv0);

    // 对遮罩图采样
    vec4 maskCol = texture(mask, v_mask_uv);

    // 片元透明度使用遮罩图透明度
    // enableMask控制是否让遮罩生效
    col.a = mix(col.a, maskCol.a, enableMask);
    gl_FragColor = col;
  }

材质属性 or 顶点属性?

自定义顶点格式这么好用,是不是可以抛弃材质属性(uniform变量)了?
NO!


用顶点属性做传参是为了实现合批渲染的一种权宜的处理方式。

  1. 顶点属性会在每个顶点上 冗余一份数据,即使这些数据是一模一样的,会少量增加内存使用和顶点数据拷贝时间。所以用顶点属性传参适合顶点数量少的渲染组件。一次draw call中统一的数据务必使用材质属性。
  2. 如果某个特效只对少数几个渲染组件生效,甚至是否合批都无所谓,建议使用材质属性进行传参

两种传参方式可以结合,实际应用中根据项目需求灵活运用。


Demo地址

8赞

:grin: GT 自定义合批系列

###niubility

谢谢分享
前段时间是在找图片遮罩这方便的技术。
正好也实现了,其实好像只要能把遮罩的纹理坐标使用自己渲染数据格式输入到shader中就可以了,好像不用什么坐标变换的。
我的实现方式是这样的。改造cc.Sprite 为MaskSprite添加一个maskFrame的cc.SpriteFrame设置遮罩的纹理帧,自定义MaskAssembler在渲染数据后面加一个UV1数据, 在更新MaskAssembler顶点的时候使用maskFrame填充UV1。在渲染器中定义mask的sampler2D, 然后直接texture(texture, v_uv0) * texture(mask, v_uv1) * v_color;就可以了。

主要是要注意cc.Sprite不要要用trim模式,而且他的spriteFrame也不能裁剪,否则会影响到渲染顶点的坐标(如果裁剪,顶点坐标可能缩小),当然maskFrame也不能裁剪,否则和spriteFrame的UV坐标对不上了。

用纹理遮罩确实不需要坐标变换,你的做法跟我上面第二种是一样的。
对透明裁剪后纹理的处理,得根据具体业务场景。Demo里是允许蒙版和底图尺寸不一样,底图总是等比缩放填充整个节点,蒙版采用拉伸的简单处理方式。如果不希望蒙版被拉伸或者蒙版被裁剪了,还是需要对蒙版做一次坐标映射。

mark一个,好东西

mark+1

请教一下,那个圆形遮罩会有个拉伸的动画,那个要怎么去掉。

float r = 0.5 - abs(sin(cc_time.x * 2.) * 0.02);    // some trivial animation

应该是这里吧,半径按时间变化的,所以有个动画

非常感谢,原来是在这里。。