[干货分享]解读多重纹理,如何自己实现一个MultiTexture

https://juejin.cn/post/7147567789966901261

完成效果

2个动画分别使用不同的Plist,最终1个DC完成绘制

最终实现效果:

GIF 2022-9-23 18-34-31.gif

原理剖析

关于多重纹理,在江南百景图的技术分析里面也有提到

关于PASS是什么?请原谅我不知道,因为我知道Shader里面并没有PASS这玩意,很显然,这是game engine层面抽象出来的概念,同样MESH也是这个道理。

要合批,必须同时满足以下3个条件,缺一不可

  • 相同的Blend
  • 相同的Texture
  • 相同的Shader

如何实现嘞?当然要从OpenGL程序的角度考虑这个问题。

最简单的绘制图片过程

理解OpenGL绘制图片的过程,对理解多重纹理如何实现非常重要!!!

以下都是伪代码,是为了方便理解

  • vertex.shader
attribute vec4 a_position;
attribute vec2 a_texCoord;
attribute vec4 a_color;

varying vec4 v_position;
varying vec2 v_texCoord;
varying vec4 v_fragmentColor;

void main() {
    gl_Position = CC_PMatrix * a_position;
    v_fragmentColor = a_color;
    v_texCoord = a_texCoord;
    v_position = a_position;
}
  • fragment.shader
varying vec2 v_texCoord;
varying vec4 v_fragmentColor;
varying vec4 v_position;
uniform sampler2D texture1;
void main() {
    gl_FragColor = v_fragmentColor * texture2D(texture1, v_texCoord);
}
  • 逻辑代码
// 顶点数据
Vertices vertices=[
    [color,position,texCoord],
    [color,position,texCoord]
]; 

// 获取属性
int positionLocation = glGetAttribLocation(shader, "a_positon");
// 启用这个属性
glEnableVertextAttribArray(positionLocation); 
// 从vertices中提取属于postion的数据,绑定到shader中定义的属性
// 我这么写隐藏了非常多的细节,主要是为了方便理解
glVertexAttriPointer(positionLocation, vertices, vertices.position); 

// ... texCoord, color 属性同理也按照上边的步骤进行设置即可

// 至此,GPU已经知道如何解析顶点数据了,其实这么描述不太准确,但是方便理解

int textureUnit = 0;

// 其实texture->getName()就是这个东西
GLint textureName; 
// 分配纹理单元
glGenTextures(1, &textureName); 
// 激活纹理单元,注意这里是0号单元
// 如果要使用其他纹理单元,向下边这样累加就行,实际上cocos2dx就是这么玩的
glActiveTexture(GL_TEXTURE0 + textureUnit); 
// 绑定分配的纹理单元到激活的纹理单元,虽然这时是textureName啥都没得
glBindTexture(GL_TEXTURE2D, textureName);

// 以上这几步,纯粹是为了建立映射关系

Bytes imageData="", imageWidth=100, imageHeight=100;
glEnable(GL_TEXTURE_2D);

// 将纹理数据绑定到上边的纹理单元:textureName
// 这个api的用法不是这样子,这样写也是为了方便理解
glTexImage2D(GL_TEXTURE_2D,imageWidth,imageHeight,imageData);

// 从fragment中获取texture1变量 
int texture = glGetUniformLocation(shader, "texture1"); 
// 设置每个采样器使用哪个纹理单元,这里我们从头到尾一直都是在操作纹理单元0
glUniform1i(texture, textureUnit);

// 完成最终的绘制
glDrawArrays(GL_TRIANGLES);

以上的流程挺长的,但这个已经是最小的绘制图片流程了,总结下就是:

  1. glVertexAttriPointe将顶点数据正确的分配个vertex.shader中的attribute,通过varying将数据传递给fragment.shader
  2. 激活指定的纹理单元,并填充纹理数据
  3. 将fragment.shader中的uniform sampler2D,通过glUniform1i和刚刚操作的纹理单元建立映射,这样texture2D的的结果就是我们刚刚填充的纹理数据了
  4. draw

合批到底是什么?

理解了以上的图片绘制,我们再从OpenGL的角度理解下,到底什么是合批?

上边的绘制图片过程我们封装为一个drawImage函数,如果我绘制2个图片,那么我们可能会这样做

drawImage();
drawImage();

调用了2次,也就触发了2次OpenGL的draw函数,但是如果一帧调用了大量的draw函数,就会产生性能压力,主要是gl的各种操作、CPU和GPU之间的数据交换,其实都是有代价的,所以你也会发现OpenGL出现了各种buffer的概念,有点类似缓存的味道。

那么有没有办法1个draw,绘制2个图片呢?

肯定有!我们如果查阅OpenGL的绘制形状,你会发现绘制三角形能满足我们的需求,因为图片也是由2个三角形组成,给你4个三角形,你肯定能拼凑出2个不同位置的四边形。

如果你仔细阅读源码,你也会发现,engine的绘制形状一定是GL_TRIANGLES

那该如何做呢?

只需要顶点数据一次传递4个三角形顶点即可!

这样,我们就一次draw,绘制了2个不同位置的图片,这个骚操作,在游戏引擎里面,称作合批,也可以理解为,使用最少的draw完成目标效果,前提是我们需要保证绘制结果正常。

多重纹理又是怎么回事

我们需要知道的是,多重纹理也是合批的一个手段,目的都是在通过一定的手段,达到合批效果。

多重纹理,顾名思义就是可以在多个预设的纹理之间切换,我们仔细观察下fragment.shader

gl_FragColor = v_fragmentColor * texture2D(texture1, v_texCoord);

因为shader可以自己编写,如果我们想在多个纹理之间切换,那么shader可能就会是这样子

int unit=0;
if( 0 == unit ){
    gl_FragColor = v_fragmentColor * texture2D(texture0, v_texCoord);
}else if( 1 == unit ){
    gl_FragColor = v_fragmentColor * texture2D(texture1, v_texCoord);
}

那么问题就变成我们怎么控制unit达到切换纹理的效果。

我们首先会想到将unit定义为uniform,这样我们在代码中通过glUniform1i(unit, 3)修改。

至此,你已经理解了,多重纹理的实现思路,本质上就是在fragment.shader中定义多个sampler2D ,在代码中修改shader中定义的变量,影响fragment采样不同的texture即可。

实现多重纹理如何合批

让我们再看下条件

  • 相同的Blend: 这个就不用解释了,肯定得一致
  • 相同的Shader:这个也不用解释了,肯定得一致
  • 相同的Texture

image.png

问题就在相同的Texture上,多重纹理使用的是多个纹理,这怎么搞?

其实仔细思考下,这个条件其实对多纹理并没有多大意义。

因为不同组合的多纹理,我们可以生成不同的Shader实例,通过这个Shader实例,就可以判断,是否满足合批条件。

当然还有另外一个办法,Spire使用的shader实例是同一个,通过shader绑定的纹理Array的md5、hash值,也可以区分是否满足合批条件。

unit怎么传递呢?

上文说的uniform方式其实是有问题的。

2个图片的顶点数据最终是揉到了一个顶点数据里面,OpenGL在绘制的时候,如何在fragment里面知道当前绘制的顶点应该采用哪个 texture unit呢?

很显然unit其实是和顶点数据相关,所以必须放在顶点数据里面。

在creator中,你可以重新定义vertex format,但是在2dx中,并没有开放vertex format,那么unit数据应该放到哪里呢?

哈哈!一个2d engine,目前并没有采用positon.z,所以就hack到这里了!最重要的是这样子不用改渲染结构,算是勉强实现了需求。

至此,整个实现思路就完全剖析完毕,开始撸代码了。

扩展知识

获取fragment支持的最大纹理单元数量

    GLint maxTextureUnits;
    glGetIntegerv(GL_MAX_TEXTURE_UNITS, &maxTextureUnits);

实际测试发现GL_MAX_TEXTURE_UNITS总是返回4,后来发现这个属性在OpenGL3中是废弃了,应该使用GL_MAX_TEXTURE_IMAGE_UNITS

属性 说明 取值
GL_MAX_TEXTURE_UNITS 支持的常规纹理(纹理坐标集+纹理图像单元)单元数量,这个数值返回的总是4,因为在OpenGL3中被弃用了 4
GL_MAX_TEXTURE_IMAGE_UNITS 片段着色器访问纹理贴图单元的数量 32
GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS 32
GL_MAX_TEXTURE_COORDS 获取最大纹理坐标 8
GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS 所有阶段着色器可以绑定的纹理单元,每个阶段可使用GL_MAX_TEXTURE_IMAGE_UNITS,一共6个阶段,所有32*6=192 192
12赞

大佬牛逼。学习了。

173b6a36cd691c4333f1fe15d3992c25134d6b0a_2_414x375

image

多一个uniform,就要多吃一点带宽
所以,还可以用现有的通道来传递参数,比如利用颜色来传递

前提是不能对现有功能造成破坏

是不是在要画具体哪张图的的时候要切换纹理的顶点信息对吧~

只需要切换纹理就行了,顶点和纹理坐标不用变

大佬总是那么多,每次逛论坛我都头皮发麻呀

大佬 666

文字标记~

mask一下