Android OpenGLES3绘图:ransform-Feedback变换反馈粒子系统

变换反馈

image.png

image.png

https://www.jianshu.com/p/0b66c00d7073
iOS 计算和读取的例子
https://wiki.jikexueyuan.com/project/modern-opengl-tutorial/tutorial28.html
PC上解释比较清晰的粒子系统
https://mathweb.ucsd.edu/~sbuss/MathCG2/OpenGLsoft/ParticlesTransformFeedback/PTFexplain.html
PC上极简的粒子系统,有win可执行文件
https://github.com/pwambach/webgl2-particles
WebGL的极简粒子系统,展示效果不错,项目完整

Transform Feedback变换反馈是将顶点着色器的计算结果输出到GPU的缓存中,然后下一次绘制可以直接将这个缓存输入到顶点着色器。这个过程使用GPU内存,不需要CPU去复制或读取数据。

下面用变换反馈实现一个简单的粒子系统。

1 粒子系统

先实现一个粒子系统,不用变换反馈。创建很多点,设置随机的位置和速度,用glDrawArrays(GL_POINTS, 0, MAX_COUNT)画点,在着色器里根据传入的时间计算点的位置并更新。


public class TransformFeedbackRenderer extends BaseRender {

   int program;
   int[] vao;
   int[] vbo;

   static int MAX_COUNT = 100000;
   float[] pos = new float[MAX_COUNT * 4]; // x,y,vx,vy
   Random r = new Random();

   float[] time = {0f, 0f, 0f}; // time,centerX,centerY

   @Override
   public void onSurfaceCreated(GL10 gl, EGLConfig config) {

      initAll();

      program = ShaderUtils.loadProgramTransformFeedback();

      // 分配内存空间,每个浮点型占4字节空间
      ByteBuffer posBuffer = ByteBuffer
              .allocateDirect(pos.length * 4)
              .order(ByteOrder.nativeOrder());

      posBuffer.position(0);
      posBuffer.asFloatBuffer().put(pos);

      vao = new int[1];
      glGenVertexArrays(1, vao, 0);
      glBindVertexArray(vao[0]);

      vbo = new int[1];
      glGenBuffers(1, vbo, 0);
      glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
      glBufferData(GL_ARRAY_BUFFER, MAX_COUNT * 4 * 4, posBuffer, GL_STATIC_DRAW);

      // Load the vertex data
      glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * 4, 0);
      glEnableVertexAttribArray(0);
      glVertexAttribPointer(1, 2, GL_FLOAT, false, 2 * 4, 2 * 4);
      glEnableVertexAttribArray(1);

      glBindBuffer(GL_ARRAY_BUFFER, 0);
      glBindVertexArray(0);

      glClearColor(0.0f, 0.0f, 0.0f, 1.0f);

   }

   @Override
   public void onSurfaceChanged(GL10 gl, int width, int height) {
      glViewport(0, 0, width, height);
//        glViewport(-(height - width) / 2, 0, height, height);
      this.width = width;
      this.height = height;
   }

   int width, height;

   @Override
   public void onDrawFrame(GL10 gl) {
      super.onDrawFrame(gl);

      // Clear the color buffer
      glClear(GL_COLOR_BUFFER_BIT);

      // Use the program object
      glUseProgram(program);

      time[0] += 0.01;

      int loc = glGetUniformLocation(program, "time");
      glUniform3fv(loc, 1, time, 0);

      glBindVertexArray(vao[0]);

      glDrawArrays(GL_POINTS, 0, MAX_COUNT);
   }

   void initAll() {
      for (int i = 0; i < pos.length; i++) {
         pos[i] = (float)r.nextInt(1000) / 1000;
         if (i % 4 < 2) {
             pos[i] = (pos[i] - 0.5f) * 2;
         } else {
             pos[i] = pos[i] - 0.5f;
         }
      }
   }
}

transform_feedback_v.glsl

#version 300 es
layout (location = 0) in vec2 vPosition;
layout (location = 1) in vec2 vVelocity;

uniform vec3 time;
uniform vec2 center;

out vec2 vPos;
out vec2 vVel;

void main() {
     vPos = vPosition;
     vVel = vVelocity;
     gl_PointSize = 5.0;

     float cx = time.g;
     float cy = time.b;
     float dx = (cx - vPos.x) * time.r;
     float dy = (cy - vPos.y) * time.r;

     float x = vPos.x + vVel.x * time.r + dx;
     float y = vPos.y + vVel.y * time.r + dy;

     gl_Position  = vec4(x, y, 0.0, 1.0);
}

image.png

效果如图,这个极简的粒子系统性能很不错。一百万个粒子在我的骁龙865手机上能够满帧运行。

如此流畅的原因是数据是一次性发送到GPU,后续的计算都在GPU。这样有一个缺点,就是每次计算用的都是顶点数据的初始值,没法在上一次的计算结果的基础上计算,就会有局限,不能实现复杂的过程。如果想利用计算结果,可以用内存映射把GPU数据复制出来,再输入给vbo进行下一次绘制,这样性能肯定会有损耗。想要无损使用GPU数据缓存,就得用变换反馈了。

2 添加变换反馈

为了添加变换反馈,这几天我在网上搜索了大量的资料,包括各种各样的Demo,它们实现的方式千奇百怪:有C++的、WebGL的、iOS的、PC的、不用vao的、不用tfo(变换反馈对象)的、不交替变换只打印数据的、禁止光栅化的、加fence同步屏障的。

在这些资料基础上我总结出了最简单的实现方式。

2.1 编译Shader

在顶点着色器中加入输出变量vPos,在glLinkProgram之前用glTransformFeedbackVaryings设置这个变量字符串。

#version 300 es
layout (location = 0) in vec2 vPosition;
layout (location = 1) in vec2 vVelocity;

out vec2 vPos;
...
    public static int loadProgramTransformFeedback() {
        int vShader = ShaderUtils.loadShader(GL_VERTEX_SHADER, loadAssets("transform_feedback_v.glsl"));
        int fShader = ShaderUtils.loadShader(GL_FRAGMENT_SHADER, loadAssets("transform_feedback_f.glsl"));
//        return linkProgram(vShader, fShader, new String[]{"vPos", "vVel"});
        return linkProgram(vShader, fShader, new String[]{"vPos"});
    }

    private static int linkProgram(int vShader, int fShader, String[] transformFeedbackVaryings) {
        int program = glCreateProgram();
        if (program == 0) {
            Log.e("chao", "program == 0");
            return 0;
        }

        glAttachShader(program, vShader);
        glAttachShader(program, fShader);

        if (transformFeedbackVaryings != null) {
            glTransformFeedbackVaryings(program, transformFeedbackVaryings, GL_INTERLEAVED_ATTRIBS);
        }

        glLinkProgram(program);
        int[] linkStatus = new int[1];
        glGetProgramiv(program, GL_LINK_STATUS, linkStatus, 0);
        if (linkStatus[0] == 0) {
            String log = glGetProgramInfoLog(program);
            Log.e("chao", "linkProgram fail " + log);
            glDeleteProgram(program);
            return 0;
        }
        return program;
    }

2.1 添加tfo变换反馈对象

用glGenTransformFeedbacks创建两个tfo,然后用glBindBufferBase方法分别绑定一个vbo(原来的vao、vbo初始化都不用修改)。

注意:变换反馈对象的本质是绑定一块内存,让计算结果输出到这块内存。它可以直接绑定vbo。这跟vbo原来绑定的vao完全不冲突。也就是说,一个vbo可以同时跟vao和tfo绑定。这让下一步交替切换很方便。

      tfo = new int[2];
      glGenTransformFeedbacks(2, tfo, 0);

      for (int i = 0; i < 2; i++) {
         glBindVertexArray(vao[i]);
         glBindBuffer(GL_ARRAY_BUFFER, vbo[i]);
         glBufferData(GL_ARRAY_BUFFER, MAX_COUNT * 4 * 2, buffer, GL_DYNAMIC_DRAW);

         // Load the vertex data
         glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * 4, 0);
         glEnableVertexAttribArray(0);
//         glVertexAttribPointer(1, 2, GL_FLOAT, false, 2 * 4, 2 * 4);
//         glEnableVertexAttribArray(1);

         glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfo[i]);
         glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, vbo[i]);
      }

2.2 交替切换tfo

用i0表示当前序号,它在0和1之间变换。

首先绑定tfo[0],接收输出的顶点数据,因为tfo[0]绑定了vbo[0],vao[0]也绑定了vbo[0],相当于数据输出到vao[0]了。用glBeginTransformFeedback开启变换反馈,绑定vao[1]画点,然后用glEndTransformFeedback结束变换反馈。为什么绑定vao[1]画点呢,因为vao[1]绑定了vbo[1],在开启了变换反馈下画点就相当于把vbo[1]的数据传给了tfo[0],tfo[0]经过计算,把数据输出到vbo[0]。数据流是这样的:vbo[1] -> tfo[0] -> vbo[0]。

i0变换后,把tfo和vao序号同时改变即可,数据流是:vbo[0] -> tfo[1] -> vbo[1]。

这样数据就在vbo[0]和vbo[1]之间交替流动,不需要经过CPU,相当于在GPU里直接使用缓存。在顶点着色器里就可以直接使用上一步的计算结果了。


   int i0 = 0;

   @Override
   public void onDrawFrame(GL10 gl) {
      super.onDrawFrame(gl);

      // Clear the color buffer
      glClear(GL_COLOR_BUFFER_BIT);

      // Use the program object
      glUseProgram(program);

      time[0] += 0.01;

      int loc = glGetUniformLocation(program, "time");
      glUniform3fv(loc, 1, time, 0);


      if (i0 == 0) {
         // 绑定tfo[0],接收输出的顶点数据,数据相当于输出到vao[0]了
         glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfo[0]);
         glBeginTransformFeedback(GL_POINTS);
         // 绑定vao[1],输入顶点数据
         glBindVertexArray(vao[1]);
         glDrawArrays(GL_POINTS, 0, MAX_COUNT);
         glEndTransformFeedback();
      } else {
         // 绑定tfo[1],接收输出的顶点数据,数据相当于输出到vao[1]了
         glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfo[1]);
         glBeginTransformFeedback(GL_POINTS);
         // 绑定vao[0],输入顶点数据
         glBindVertexArray(vao[0]);
         glDrawArrays(GL_POINTS, 0, MAX_COUNT);
         glEndTransformFeedback();
      }

      // 交换
      i0 = 1 - i0;
   }

2.3 输出多变量问题

一开始我准备用tfo输出vPos和vVel两个vec2类型的变量,但是输出结果一直错乱, 找了很多地方也没解决问题。但是当我把输出变量改成只有一个vPos,问题就莫名其妙地解决了,变换反馈能正常运行了,这就说明主要流程没有问题。只是使用多个输出变量时,顶点数据在某一步写入或读取时出了问题。这个问题我还在查找,如果有大佬知道原因,请赐教一下。

3 变换反馈粒子系统

在Github上看到这个WebGL实现的粒子系统,它还有演示的网站Live Demo,效果不错。同事说有点水墨中国风的样子。那么我把它搬到手机上来。

image.png

该项目绘制的核心代码在calc_vertex.js文件里,稍微修改就能使用了。它的逻辑是:取屏幕上一个点作为中心,所有的点受到这个中心的引力,向它加速移动。引力转换为加速度,加速度转换为速度,根据当前点的速度和位置算出下个时间点的速度和位置,输出到vec4 vPos里面(由于我前面遇到的问题,这里使用一个vec4打包位置和速度)。

变换反馈的部分在前面基础上不用修改。

#version 300 es
layout (location = 0) in vec4 aPos;
//layout (location = 1) in vec2 vVelocity;

out vec4 vPos;
//out vec2 vVel;

uniform vec3 time;

float PARTICLE_MASS = 1.0;
float GRAVITY_CENTER_MASS = 100.0;
float DAMPING = 2e-6;
void main() {
     vec2 gravityCenter = time.gb;

     float r = distance(aPos.xy, gravityCenter);
     vec2 direction = gravityCenter - aPos.xy;
     float force = PARTICLE_MASS * GRAVITY_CENTER_MASS / (r * r) * DAMPING;
     float maxForce = min(force, DAMPING * 10.0);

     vec2 acceleration = force / PARTICLE_MASS * direction;
     vec2 newVelocity = aPos.zw + acceleration;
     vec2 newPosition = aPos.xy + newVelocity;
     vec2 v_velocity = newVelocity * 0.99;
     vec2 v_position = newPosition;
     // bounce at borders
     if (v_position.x > 1.0 || v_position.x < -1.0) {
          v_velocity.x *= -0.5;
     }
     if (v_position.y > 1.0 || v_position.y < -1.0) {
          v_velocity.y *= -0.5;
     }
     vPos = vec4(v_position, v_velocity * 0.99);
     gl_Position  = vec4(v_position, 0.0, 1.0);
     gl_PointSize = 2.0;
}

中心点可以根据屏幕触摸位置更新。

    glSurfaceView.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            if (render instanceof TransformFeedbackRenderer) {
                ((TransformFeedbackRenderer) render).updateCenter(event.getX(), event.getY());
            }
            return true;
        }
    });

   float[] time = {0f, 0f, 0f}; // time,centerX,centerY

   public void updateCenter(float x, float y) {
      time[1] = (x / width - 0.5f) * 2;
      time[2] = -(y / height - 0.5f) * 2;
   }

      int loc = glGetUniformLocation(program, "time");
      glUniform3fv(loc, 1, time, 0);

4 一些注意点

禁用光栅化和计算绘制分离

有的Demo提到做变换反馈时用glEnable(GL_RASTERIZER_DISCARD)来禁用光栅化。一开始我以为必须这样,其实这并不是必要的过程。也就是说,变换反馈不影响正常绘制,变换反馈的计算结果可以同时输出到缓存和绘制到屏幕;顶点着色器可以同时输出结果到变换反馈的缓存和片段着色器。如果做简单的Demo,没有必要禁止光栅化,也没有必要为变换反馈使用一套单独的着色器和program。

禁用光栅化的使用场景是将变换反馈顶点计算与最终绘制分离,需要使用两套着色器程序,一套用来做变换反馈计算顶点数据,一套用来真正绘制。

大致过程如下,我没有真正实现,有兴趣可以实现一下。


      int program, tfProgram;
      int vao,vbo,tfo;
      int vao1,vbo1,tfo1;

      // 禁用光栅化,使用变换反馈程序tfProgram,绑定vao输入,输出到tfo1(绑定了vao1)
      glUseProgram(tfProgram);
      glEnable(GL_RASTERIZER_DISCARD);
      glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfo1);
      glBeginTransformFeedback(GL_POINTS);
      glBindVertexArray(vao);
      glDrawArrays(GL_POINTS, 0, particles);
      glEndTransformFeedback();

      // 开启光栅化,使用绘图程序program,绑定vao1输入(tfo1的计算结果),绘制到屏幕
      glDisable(GL_RASTERIZER_DISCARD);
      glUseProgram(program);
      glBindVertexArray(vao1);
      glDrawArrays(GL_POINTS, 0, particles);

fence同步
有的Demo提到用fence同步来防止绘制不完整,这个应该是在多线程或更复杂的场景中使用,先插入一个fence,在其他线程等待,完成后删除它:

fenceSyncObject = GLES30.glFenceSync(GLES30.GL_SYNC_GPU_COMMANDS_COMPLETE, 0)
GLES30.glWaitSync(fenceSyncObject, 0, GLES30.GL_TIMEOUT_IGNORED) 
GLES30.glDeleteSync(fenceSyncObject)

glDrawTransformFeedback
有的Demo在变换反馈时用glDrawTransformFeedback方法绘制,OpenGL ES3应该没有这个方法,用普通的glDrawArrays方法即可。

我的思考
变换反馈用一种特殊的方式实现了GPU缓存,相比普通的GPU缓存来说它没那么直观好理解。它的优点在于把缓存用GPU并行机制分配给顶点着色器,省去了从数组里遍历或者用下标“找点”的过程,有比较好的性能。

posted @ 2022-07-18 18:03  rome753  阅读(285)  评论(0编辑  收藏  举报