2

Project origin

After the understanding of GLSL and the baptism of various projects on shadertoy, it should not be a difficult problem to develop simple interactive graphics now. Let's start to do GLSL renderer replacement development for some existing business logic projects.

The reason is that I saw some small game advertisements, and I felt that the mechanism was interesting, and the implementation should not be very complicated, so I tried to develop one by myself.

/img/bVcVGQN

The game is very simple. Like a bubble shooter, bubbles of different sizes are shot from the bottom of the screen, and the bubbles rise to the top. Bubbles of the same color can be merged into a higher level of different color bubbles. Simply put, it is a synthetic watermelon that is turned upside down.

The more special part is to express the texture of the bubbles. When bubbles of the same color are approached, the surface of the water droplets will merge first. This part needs to be realized by shader rendering.

Project structure

Layer logic first

The top layer is the game business logic Game , which manages the start and end states of the game, responds to user input, and records game scores.

The second is the game logic driver layer Engine , which manages game elements, exposes actions that can be controlled by the user, and references the renderer to control the rendering and updating of the game scene.

Further down is the physics engine module Physics , which manages the relationship between game elements and the interfaces needed to Engine

Parallel to the engine module is the renderer module Renderer , which reads Engine and renders the game scene.

The advantage of this layering is that each module can be replaced/modified independently; for example, before the development of the GLSL renderer is completed, it can be replaced with other renderers, such as 2D canvas renderer, or even rendered using HTML DOM.

The structure diagram is as follows:

/img/bVcVGQO

Game logic implementation

Game business logic Game

Because the game business is relatively simple, this layer is only responsible for these things:

  1. Enter the HTML canvas element to specify the game rendering range
  2. Initialize the driver layer Engine
  3. Monitor user operation event touchend/click , call Engine control the shooting of bubbles
  4. Cycle call Engine of update update method, and check the height of the bubble more than a specified amount, such as the number of more than 0 to stop playing
class Game {
  constructor(canvas) {
    this.engine = new Engine(canvas)
    document.addEventListener('touchend', (e) => {
      if(!this.isEnd) {
        this.shoot({
          x: e.pageX,
          y: e.pageY
        }, randomLevel())
      }
    })
  }
  shoot(pos, newBallLevel) {
    // 已准备好的泡泡射出去
    this.engine.shoot(pos, START_V)
    // 在初始点生成新的泡泡
    this.engine.addStillBall(BALL_INFO[newBallLevel])
  }
  update() {
    this.engine.update()
    let point = 0;
    let overflowCount = 0;
    this.engine.physics.getAllBall().forEach(ball => {
      if(!ball.isStatic){
        point += Math.pow(2, ball.level);
        if (ball.position.y > _this.sceneSize.width * 1.2) {
          overflowCount++
        }
      }
    })
    if(overflowCount > 1){
      this.gameEnd(point);
    }
  }
  gameEnd(point) {
    this.isEnd = true
    ...
  }
}

Drive layer Engine

The logic of this layer is responsible for managing the physics engine Physics and the renderer module Renderer , and exposes interactive methods for Game call.

Specify the physics engine module to provide the following interface methods:

  1. Generate a fixed bubble at the specified location for the user to use in the next operation
  2. Shoot the fixed bubble in the specified direction

In the update method update , read the location, size, level and color information of all bubbles, and then call the renderer to render the bubbles.

class Engine {
  constructor(canvas) {
    this.renderer = new Renderer(canvas)
    this.physics = new Physics()
  }
  addStillBall({ pos, radius, level }) {
    this.physics.createBall(pos, radius, level, true)
    this.updateRender()
  }
  shoot(pos, startV) {
    this.physics.shoot(pos, startV)
  }
  updateRender() {
    // 更新渲染器渲染信息
  }
  update() {
    // 调用渲染器更新场景渲染
    this.renderer.draw()
  }
}

Physics engine module Physics

The physics engine uses matter.js . There is no other reason. It is because of previous project experience and a renderer that can be used to assist our own rendering development.

Including the driver layer mentioned in the previous section, the physics engine module needs to implement the following functions:

  1. Generate a fixed bubble at the specified location for the user to use in the next operation
  2. Shoot the fixed bubble in the specified direction
  3. Check if bubbles of the same color collide
  4. The collided bubbles of the same color merge into a higher-level bubble

Before that, we first need to initialize the scene:

0. Scene building

The left, right, and bottom borders are implemented using ordinary rectangular collision bodies.

Semicircular top using a pre-marked SVG pattern, using matter.js in SVG class pathToVertices generating method of collision body, is inserted into the scene.

Because bubbles are all floating upwards, the direction of gravity is the negative direction of the y-axis.

// class Physics

constructor() {
  this.matterEngine = Matter.Engine.create()
  // 置重力方向为y轴负方向(即为上)
  this.matterEngine.world.gravity.y = -1

  // 添加三面墙
  Matter.World.add(this.matterEngine.world, Matter.Bodies.rectangle(...))
  ...
  ...

  // 添加上方圆顶
  const path = document.getElementById('path')
  const points = Matter.Svg.pathToVertices(path, 30)
  Matter.World.add(this.matterEngine.world, Matter.Bodies.fromVertices(x, y, [points], ...))

  Matter.Engine.run(this.matterEngine)
}

1. Generate a fixed bubble at the specified location for the user to use in the next operation

Create a circular collider and place it in the specified position of the scene, and record it as Physics for the injection method.

// class Physics

createBall(pos, radius, level, isStatic) {
  const ball = Matter.Bodies.circle(pos.x, pos.y, radius, {
    ...// 不同等级不同的大小通过scale区分
  })
  // 如果生成的是固定的泡泡,则记录在属性上供下次射出时使用
  if(isStatic) {
    this.stillBall = ball
  }
  Matter.World.add(this.matterEngine.world, [ball])
}

2. Shoot the fixed bubble in the specified direction

The shooting direction is determined by the user's click position, but the shooting speed is fixed.

It can be calculated by clicking on the vector connecting the position and the original position, normalizing it and multiplying it by the initial velocity.

// class Physics

// pos: 点击位置,用于计算射出方向
// startV: 射出初速度
shoot(pos, startV) {
  if(this.stillBall) {
    // 计算点击位置与原始位置的向量,归一化(使长度为1)之后乘以初始速度大小
    let v = Matter.Vector.create(pos.x - this.stillBall.position.x, pos.y - this.stillBall.position.y) 
    v = Matter.Vector.normalise(v)
    v = Vector.mult(v, startV)

    // 设置泡泡为可活动的,并把初速度赋予泡泡
    Body.setStatic(this.stillBall, false);
    Body.setVelocity(this.stillBall, v);
  }
}

3. Check if bubbles of the same color collide

In fact, matter.js collisionStart event that is triggered when two colliders collide, but for the bubbles generated after the collision, even if they touch the bubbles of the same color, this event will not be triggered, so they can only be detected manually Whether two bubbles collide.

The method used here is to determine whether the distance between the centers of the two circles is less than or equal to the sum of the radii, if it is, it is judged as a collision.

// class Physics

checkCollision() {
  // 拿到活动中的泡泡碰撞体的列表
  const bodies = this.getAllBall()
  let targetBody, srcBody
  // 逐对泡泡碰撞体遍历
  for(let i = 0; i < bodies.length; i++) {
    const bodyA = bodies[i]
    for(let j = i + 1; j < bodies.length; j++) {
      const bodyB = bodies[j]
      if(bodyA.level === bodyB.level) {
        // 用距离的平方比较,避免计算开平方
        if(getDistSq(bodyA.position, bodyB.position) <= 4 * bodyA.circleRadius * bodyA.circleRadius) {
          // 使用靠上的泡泡作为目标泡泡
          if(bodyA.position.y < bodyB.position.y) {
            targetBody = bodyA
            srcBody = bodyB
          } else {
            targetBody = bodyB
            srcBody = bodyA
          }
          return {
            srcBody,
            targetBody
          }
        }
      }
    }
  }
  return false
}

4. Bubbles of the same color that collided are merged into a higher-level bubble

For the two bubbles that collide, the upper one of the y-coordinates is used as the merged target, and the lower one is used as the source bubble. The merged bubble's coordinates are set on the target bubble's coordinates.

The source bubble collision is set to off and set to a fixed position;

If you only realize the merge function, you only need to set the position of the source bubble to the coordinates of the target bubble, but in order to realize the animation transition, the position of the source bubble is moved as follows:

  1. Calculate the difference between the position of the source bubble and the target bubble in each update cycle to obtain the vector that the source bubble needs to move
  2. 1/8 movement vector, repeat operations 1, 2 in the next update cycle
  3. When the difference between the positions of the two bubbles is less than a small value (here set to 5), the merge is considered complete, the source bubble is destroyed, and the level information of the target bubble is updated
// class Physics

mergeBall(srcBody, targetBody, callback) {
  const dist = Math.sqrt(getDistSq(srcBody.position, targetBody.position))
  // 源泡泡位置设为固定的,且不参与碰撞
  Matter.Body.setStatic(srcBody, true)
  srcBody.collisionFilter.mask = mergeCategory
  // 如果两个泡泡合并到距离小于5的时候, 目标泡泡升级为上一级的泡泡
  if(dist < 5) {
    // 合并后的泡泡的等级
    const newLevel = Math.min(targetBody.level + 1, 8)
    const scale = BallRadiusMap[newLevel] / BallRaiusMap[targetBody.level]
    // 更新目标泡泡信息
    Matter.Body.scale(targetBody, scale, scale)
    Matter.Body.set(targetBody, {level: newLevel})
    Matter.World.remove(this.matterEngine.world, srcBody)
    callback()
    return
  }
  // 需要继续播放泡泡靠近动画
  const velovity = {
    x: targetBody.position.x - srcBody.position.x,
    y: targetBody.position.y - srcBody.position.y
  };
  // 泡泡移动速度先慢后快
  velovity.x /= dist / 8;
  velovity.y /= dist / 8;
  Matter.Body.translate(srcBody, Matter.Vector.create(velovity.x, velovity.y));
}

Because a custom method is used to detect bubble collisions, we need to bind the collision detection and merge bubble method calls on the beforeUpdate event of the physics engine

// class Physics

constructor() {
  ...

  Matter.Events.on(this.matterEngine, 'beforeUpdate', e => {
    // 检查是否有正在合并的泡泡,没有则检测是否有相同颜色的泡泡碰撞
    if(!this.collisionInfo) {
      this.collisionInfo = this.checkCollision()
    }
    if(this.collisionInfo) {
      // 若有正在合并的泡泡,(继续)调用合并方法,在合并完成后清空属性
      this.mergeBall(this.collisionInfo.srcBody, this.collisionInfo.targetBody, () => {
        this.collistionInfo = null
      })
    }
  }) 

  ...
}

Renderer module

The implementation of the GLSL renderer is more complicated. At present, you can use the built-in renderer of matter.js

In the Physics module, initialize a matter.js of render :

class Physics {
  constructor(...) {
    ...
    this.render = Matter.Render.create(...)
    Matter.Render.run(this.render)
  }
}

/img/bVcVGQP

Develop a custom renderer

Next, it's time to talk about the implementation of the renderer.

Let me first talk about how the effect of two drops of liquid approaching and the edges merged is achieved.

/img/bVcVGQQ

If we take off the glasses or put the focus farther, we can probably see this image:

/img/bVcVGQR

Seeing this, some people may guess how it is achieved.

Yes, it is to use two circles with radial gradient of brightness at the edges. At the position where their gradient edges are superimposed, the sum of brightness can reach the center of the circle.

Then add a step function filter (set to 0 if a value is lower than a certain value, and set 1 if it is higher than a certain value) on the graph of this gradient edge, and the effect of the first picture can be obtained.

Shader structure

Because the number of bubbles is always changing, and the for cycle judgment condition of the fragmentShader i < length ) must be judged with a constant (that is, length must be a constant).

So here the bubble coordinates are passed as vertex coordinates to the vertex shader vertexShader to initially render the bubble outline:

// 顶点着色器 vertexShader
attribute vec2 a_Position;
attribute float a_PointSize;

void main() {
  gl_Position = vec4(a_Position, 0.0, 1.0);
  gl_PointSize = a_PointSize;
}
// 片段着色器 fragmentShader
#ifdef GL_ES
precision mediump float;
#endif

void main() {
  float d = length(gl_PointCoord - vec2(0.5, 0.5));
  float c = smoothstep(0.40, 0.20, d);
  gl_FragColor = vec4(vec3(c), 1.0);
}
// 渲染器 Renderer.js
class GLRenderer {
  ...
  // 更新游戏元素数据
  updateData(posData, sizeData) {
    ...
    this.posData = new Float32Array(posData)
    this.sizeData = new Float32Array(sizeData)
    ...
  }
  // 更新渲染
  draw() {
    ...
    // 每个顶点取2个数
    this.setAttribute(this.program, 'a_Position', this.posData, 2, 'FLOAT')
    // 每个顶点取1个数
    this.setAttribute(this.program, 'a_PointSize', this.sizeData, 1, 'FLOAT')
    ...
  }
}

In the js code of the x , y coordinates of each point are combined into a one-dimensional array and passed to the a_Position attribute of the shader; the diameter of each point is also formed into an array and passed to the a_PointSize attribute of the shader .

Then call WebGL of drawArray(gl.POINTS) ways to draw a point, so that each bubble up into a vertex.

The vertex is rendered as a square by default, so in the fragment shader, we take the coordinates of the vertex rendering range (built-in attribute) gl_PointCoord to the vertex center ( vec2(0.5, 0.5) ) to draw a circle with a radial gradient of edge brightness.

As shown in the figure below, we should be able to get the same effect as each bubble is rendered into a light bulb:

Note that the WebGL context here needs to specify the mixed pixel algorithm, otherwise the range of each vertex will cover the original image, and each bubble has a square border on the look and feel.
gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
gl.enable(gl.BLEND);

/img/bVcVGQW

As mentioned above, we also need to add a step function filter to this image; but we cannot directly use the step function to process the output on the fragment shader above, because it is rendered independently for each vertex, not There will be information that other vertices are within the current vertex range, and there will be no possibility of the calculation of "brightness addition" mentioned earlier.

One idea is to use the rendered image of the above shader as a texture, and perform step function processing on another set of shaders for the final actual output.

For such multi-level processing, WebGL recommends using the FrameBuffer container to draw the rendering results on it; the entire complete rendering process is as follows:

Bubble drawing --> frameBuffer --> texture --> Step function filter --> canvas

The method of using frameBuffer is as follows:

// 创建frameBuffer
var frameBuffer = gl.createFramebuffer()
// 创建纹理texture
var texture = gl.createTexture()
// 绑定纹理到二维纹理
gl.bindTexture(gl.TEXTURE_2D, texture)
// 设置纹理信息,注意宽度和高度需是2的次方幂,纹理像素来源为空
gl.texImage2D(
  gl.TEXTURE_2D,
  0,
  gl.RGBA,
  1024,
  1024,
  0,
  gl.RGBA,
  gl.UNSIGNED_BYTE,
  null
)
// 设置纹理缩小滤波器
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
// frameBuffer与纹理绑定
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)

Use the following method to specify frameBuffer as the render target:

gl.bindFramebuffer(gl.FRAMEBUFFER, frameBuffer)

When the frameBuffer completed, it will be automatically stored in 0 for the second shader rendering.

// 场景顶点着色器 SceneVertexShader
attribute vec2 a_Position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;

void main() {
  gl_Position = vec4(a_Position, 0.0, 1.0);
  v_texcoord = a_texcoord;
}
// 场景片段着色器 SceneFragmentShader
#ifdef GL_ES
precision mediump float;
#endif

varying vec2 v_texcoord;
uniform sampler2D u_sceneMap;

void main() {
  vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
  d = smoothstep(0.6, 0.7, mapColor.r);
  gl_FragColor = vec4(vec3(d), 1.0);
}

The scene shader inputs 3 parameters, which are:

  1. a_Position : The vertex coordinates of the surface where the texture is rendered, because the texture here is all over the canvas, so it is the four corners of the canvas
  2. a_textcoord : The texture uv coordinates of each vertex, because the texture size and the rendering size are different (the texture size is 1024*1024 , the rendering size is the canvas size), so it is from (0.0, 0.0) to (width / 1024, height / 1024)
  3. u_sceneMap : texture number, the first texture used, 0
// 渲染器 Renderer.js
class Renderer {
  ...
  drawScene() {
    // 把渲染目标设回画布
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    // 使用渲染场景的程序
    gl.useProgram(sceneProgram);
    // 设置4个顶点座标
    this.setAttribute(this.sceneProgram, "a_Position", new Float32Array([
      -1.0,
      -1.0,

      1.0,
      -1.0,

      -1.0,
      1.0,

      -1.0,
      1.0,

      1.0,
      -1.0,

      1.0,
      1.0
    ]), 2, "FLOAT");
    // 设置顶点座标的纹理uv座标
    setAttribute(sceneProgram, "a_texcoord", new Float32Array([
      0.0,
      0.0,

      canvas.width / MAPSIZE,
      0.0,

      0.0,
      canvas.height / MAPSIZE,

      0.0,
      canvas.height / MAPSIZE,

      canvas.width / MAPSIZE,
      0.0,

      canvas.width / MAPSIZE,
      canvas.height / MAPSIZE
    ]), 2, "FLOAT");
    // 设置使用0号纹理
    this.setUniform1i(this.sceneProgram, 'u_sceneMap', 0);
    // 用画三角形面的方法绘制
    this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
  }
}

/img/bVcVGQ0

The difference between different types of bubbles

In the previous section, the drawing of bubbles of different positions and sizes in the game on the canvas was realized, and the effect of bonding between bubbles was also realized, but all the bubbles are the same color and cannot be combined. There is also a bonding effect between the bubbles, which is not the effect we want;

In this section, we distinguish between these different types of bubbles.

To distinguish various types of bubbles, you can pass in only a certain type of bubble information in the first set of shaders, and draw the texture repeatedly for the second set of scene shaders. But drawing only one type of bubble at a time will increase the number of draws a lot.

In fact, in the scene shader in the previous section, only the red channel is used, and the values of the green and blue channels are the same as the red:

d = smoothstep(0.6, 0.7, mapColor.r);

In fact, we can rgb (if the value of the alpha channel is 0, the value of the rgb channel is different from the set value, so it cannot be used), so that it can be used in a drawing process Draw 3 types of bubbles; there are 8 types of bubbles, which need to be rendered in 3 groups. When we draw bubbles in the first set of shaders, we increase the incoming drawing group and bubble level data.

varying type data between the vertex shader and the fragment shader to rgb channel the bubble uses.

// 修改后的顶点着色器 vertexShader
uniform int group;// 绘制的组序号
attribute vec2 a_Position;
attribute float a_Level;// 泡泡的等级
attribute float a_PointSize;
varying vec4 v_Color;// 片段着色器该使用哪个rgb通道

void main() {
  gl_Position = vec4(a_Position, 0.0, 1.0);
  gl_PointSize = a_PointSize;
  if(group == 0){
    if(a_Level == 1.0){
      v_Color = vec4(1.0, 0.0, 0.0, 1.0);// 使用r通道
    }
    if(a_Level == 2.0){
      v_Color = vec4(0.0, 1.0, 0.0, 1.0);// 使用g通道
    }
    if(a_Level == 3.0){
      v_Color = vec4(0.0, 0.0, 1.0, 1.0);// 使用b通道
    }
  }
  if(group == 1){
    if(a_Level == 4.0){
      v_Color = vec4(1.0, 0.0, 0.0, 1.0);
    }
    if(a_Level == 5.0){
      v_Color = vec4(0.0, 1.0, 0.0, 1.0);
    }
    if(a_Level == 6.0){
      v_Color = vec4(0.0, 0.0, 1.0, 1.0);
    }
  }
  if(group == 2){
    if(a_Level == 7.0){
      v_Color = vec4(1.0, 0.0, 0.0, 1.0);
    }
    if(a_Level == 8.0){
      v_Color = vec4(0.0, 1.0, 0.0, 1.0);
    }
    if(a_Level == 9.0){
      v_Color = vec4(0.0, 0.0, 1.0, 1.0);
    }
  }
}
// 修改后的片段着色器 fragmentShader
#ifdef GL_ES
precision mediump float;
#endif

varying vec4 v_Color;

void main(){
  float d = length(gl_PointCoord - vec2(0.5, 0.5));
  float c = smoothstep(0.40, 0.20, d);
  gl_FragColor = v_Color * c;
}

The scene fragment shader performs step function processing on the three channels (the vertex shader remains unchanged), and also passes in the drawing group number to distinguish different types of bubble colors:

// 修改后的场景片段着色器
#ifdef GL_ES
precision mediump float;
#endif

varying vec2 v_texcoord;
uniform sampler2D u_sceneMap;
uniform vec2 u_resolution;
uniform int group;

void main(){
  vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
  float d = 0.0;
  vec4 color = vec4(0.0);
  if(group == 0){
    if(mapColor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.r);
      color += vec4(0.86, 0.20, 0.18, 1.0) * d;
    }
    if(mapColor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.g);
      color += vec4(0.80, 0.29, 0.09, 1.0) * d;
    }
    if(mapColor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.b);
      color += vec4(0.71, 0.54, 0.00, 1.0) * d;
    }
  }
  if(group == 1){
    if(mapColor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.r);
      color += vec4(0.52, 0.60, 0.00, 1.0) * d;
    }
    if(mapColor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.g);
      color += vec4(0.16, 0.63, 0.60, 1.0) * d;
    }
    if(mapColor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.b);
      color += vec4(0.15, 0.55, 0.82, 1.0) * d;
    }
  }
  if(group == 2){
    if(mapColor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.r);
      color += vec4(0.42, 0.44, 0.77, 1.0) * d;
    }
    if(mapColor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.g);
      color += vec4(0.83, 0.21, 0.51, 1.0) * d;
    }
    if(mapColor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapColor.b);
      color += vec4(1.0, 1.0, 1.0, 1.0) * d;
    }
  }
  gl_FragColor = color;
}

Here, it is used to draw 3 texture images multiple times, and merge them into the final rendered image after processing. The scene shader draws 3 times, which needs to keep the last drawing result in each drawing; and the default WebGL drawing process, The image will be cleared every time it is drawn, which requires modifying this default process:

// 设置WebGL每次绘制时不清空图像
var gl = canvas.getContext('webgl', {
  preserveDrawingBuffer: true
});
class Renderer {
  ...
  update() {
    gl.clear(gl.COLOR_BUFFER_BIT)// 每次绘制时手动清空图像
    this.drawPoint()// 绘制泡泡位置、大小
    this.drawScene()// 增加阶跃滤镜
  }
}

/img/bVcVGQ1

After the above processing, the whole game has been basically completed. Above this, you can modify the style of the bubble, add the part of the score display, etc.

The complete project source code can be accessed: https://github.com/wenxiongid/bubble

Welcome to pay attention to the Bump Lab blog: aotu.io

Or follow the AOTULabs official account (AOTULabs) and push articles from time to time.


凹凸实验室
2.3k 声望5.5k 粉丝

凹凸实验室(Aotu.io,英文简称O2) 始建于2015年10月,是一个年轻基情的技术团队。