项目起因

经过对 GLSL 的了解,以及 shadertoy 上各种项目的洗礼,现在开发简单交互图形应该不是一个怎么困难的问题了。下面开始来对一些已有业务逻辑的项目做GLSL渲染器替换开发。

起因是看到某些小游戏广告,感觉机制有趣,实现起来应该也不会很复杂,就尝试自己开发一个。

游戏十分简单,类似泡泡龙一样的从屏幕下方中间射出不同颜色大小的泡泡,泡泡上浮到顶部,相同颜色的泡泡可以合并成大一级的不同颜色泡泡。简单说就是一个上下反过来的合成大西瓜。

较特别的地方是为了表现泡泡的质感,在颜色相同的泡泡靠近时,会有水滴表面先合并的效果,这一部分就需要用到着色器渲染来实现了。

项目结构

先对逻辑分层

最上层为游戏业务逻辑Game,管理游戏开始、结束状态,响应用户输入,记录游戏分数等。

其次为游戏逻辑驱动层Engine,管理游戏元素,暴露可由用户控制的动作,引用渲染器控制游戏场景渲染更新。

再往下是物理引擎模块Physics,管理游戏元素之间的关系,以及实现Engine需要的接口。

与引擎模块并列的是渲染器模块Renderer,读取从Engine输入的游戏元素,渲染游戏场景。

这样分层的好处是,各个模块可以独立替换/修改;例如在GLSL渲染器开发完成前,可以替换成其他的渲染器,如2D canvas渲染器,甚至使用HTML DOM来渲染。

结构图如下:

游戏逻辑实现

游戏业务逻辑 Game

因为游戏业务比较简单,这一层只负责做这几件事:

  1. 输入HTML canvas元素,指定游戏渲染范围
  2. 初始化驱动层Engine
  3. 监听用户操作事件touchend/click,调用Engine控制射出泡泡
  4. 循环调用Engineupdate更新方法,并检查超过指定高度的泡泡数量,如数量超过0则停止游戏
  1. class Game {
  2. constructor(canvas) {
  3. this.engine = new Engine(canvas)
  4. document.addEventListener('touchend', (e) => {
  5. if(!this.isEnd) {
  6. this.shoot({
  7. x: e.pageX,
  8. y: e.pageY
  9. }, randomLevel())
  10. }
  11. })
  12. }
  13. shoot(pos, newBallLevel) {
  14. // 已准备好的泡泡射出去
  15. this.engine.shoot(pos, START_V)
  16. // 在初始点生成新的泡泡
  17. this.engine.addStillBall(BALL_INFO[newBallLevel])
  18. }
  19. update() {
  20. this.engine.update()
  21. let point = 0;
  22. let overflowCount = 0;
  23. this.engine.physics.getAllBall().forEach(ball => {
  24. if(!ball.isStatic){
  25. point += Math.pow(2, ball.level);
  26. if (ball.position.y > _this.sceneSize.width * 1.2) {
  27. overflowCount++
  28. }
  29. }
  30. })
  31. if(overflowCount > 1){
  32. this.gameEnd(point);
  33. }
  34. }
  35. gameEnd(point) {
  36. this.isEnd = true
  37. ...
  38. }
  39. }

驱动层 Engine

这一层的逻辑负责管理物理引擎Physics和渲染器模块Renderer,并暴露交互方法供Game调用。

指定了物理引擎模块需提供以下接口方法:

  1. 在指定的位置生成固定的泡泡,供用户作下一次操作时使用
  2. 把固定的泡泡按指定的方向射出

在更新方法update里,读取所有泡泡所在的位置和大小、等级颜色信息,再调用渲染器渲染泡泡。

  1. class Engine {
  2. constructor(canvas) {
  3. this.renderer = new Renderer(canvas)
  4. this.physics = new Physics()
  5. }
  6. addStillBall({ pos, radius, level }) {
  7. this.physics.createBall(pos, radius, level, true)
  8. this.updateRender()
  9. }
  10. shoot(pos, startV) {
  11. this.physics.shoot(pos, startV)
  12. }
  13. updateRender() {
  14. // 更新渲染器渲染信息
  15. }
  16. update() {
  17. // 调用渲染器更新场景渲染
  18. this.renderer.draw()
  19. }
  20. }

物理引擎模块 Physics

物理引擎使用了matter.js,没别的原因,就是因为之前有项目经验,并且自带一个渲染器,可以拿来辅助我们自己渲染的开发。

包括上一节驱动层提到的,物理引擎模块需要实现以下几个功能:

  1. 在指定的位置生成固定的泡泡,供用户作下一次操作时使用
  2. 把固定的泡泡按指定的方向射出
  3. 检查是否有相同颜色的泡泡相撞
  4. 相撞的相同颜色泡泡合并为高一级的泡泡

在这之前我们先需要初始化场景:

0.场景搭建

左、右、下的边框使用普通的矩形碰撞体实现。

顶部的半圆使用预先画好的SVG图形,使用matter.jsSVG类的pathToVertices方法生成碰撞体,插入到场景中。

因为泡泡都是向上漂浮的,所以置重力方向为y轴的负方向。

  1. // class Physics
  2. constructor() {
  3. this.matterEngine = Matter.Engine.create()
  4. // 置重力方向为y轴负方向(即为上)
  5. this.matterEngine.world.gravity.y = -1
  6. // 添加三面墙
  7. Matter.World.add(this.matterEngine.world, Matter.Bodies.rectangle(...))
  8. ...
  9. ...
  10. // 添加上方圆顶
  11. const path = document.getElementById('path')
  12. const points = Matter.Svg.pathToVertices(path, 30)
  13. Matter.World.add(this.matterEngine.world, Matter.Bodies.fromVertices(x, y, [points], ...))
  14. Matter.Engine.run(this.matterEngine)
  15. }

1.在指定的位置生成固定的泡泡,供用户作下一次操作时使用

创建一个圆型碰撞体放到场景的指定位置,并记录为Physics的内部属性供射出方法使用。

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

2.把固定的泡泡按指定的方向射出

射出的方向由用户的点击位置决定,但射出的速度是固定的。

可以通过点击位置和原始位置连线的向量,作归一化后乘以初速度大小计算。

  1. // class Physics
  2. // pos: 点击位置,用于计算射出方向
  3. // startV: 射出初速度
  4. shoot(pos, startV) {
  5. if(this.stillBall) {
  6. // 计算点击位置与原始位置的向量,归一化(使长度为1)之后乘以初始速度大小
  7. let v = Matter.Vector.create(pos.x - this.stillBall.position.x, pos.y - this.stillBall.position.y)
  8. v = Matter.Vector.normalise(v)
  9. v = Vector.mult(v, startV)
  10. // 设置泡泡为可活动的,并把初速度赋予泡泡
  11. Body.setStatic(this.stillBall, false);
  12. Body.setVelocity(this.stillBall, v);
  13. }
  14. }

3.检查是否有相同颜色的泡泡相撞

其实matter.js是有提供两个碰撞体碰撞时触发的collisionStart事件的,但是对于碰撞后合并生成的泡泡,即使与相同颜色的泡泡触碰,也不会触发这个事件,所以只能手动去检测两个泡泡是否碰撞。

这里使用的方法是判断两个圆形的中心距离,是否小于等于半径之和,是则判断为碰撞。

  1. // class Physics
  2. checkCollision() {
  3. // 拿到活动中的泡泡碰撞体的列表
  4. const bodies = this.getAllBall()
  5. let targetBody, srcBody
  6. // 逐对泡泡碰撞体遍历
  7. for(let i = 0; i < bodies.length; i++) {
  8. const bodyA = bodies[i]
  9. for(let j = i + 1; j < bodies.length; j++) {
  10. const bodyB = bodies[j]
  11. if(bodyA.level === bodyB.level) {
  12. // 用距离的平方比较,避免计算开平方
  13. if(getDistSq(bodyA.position, bodyB.position) <= 4 * bodyA.circleRadius * bodyA.circleRadius) {
  14. // 使用靠上的泡泡作为目标泡泡
  15. if(bodyA.position.y < bodyB.position.y) {
  16. targetBody = bodyA
  17. srcBody = bodyB
  18. } else {
  19. targetBody = bodyB
  20. srcBody = bodyA
  21. }
  22. return {
  23. srcBody,
  24. targetBody
  25. }
  26. }
  27. }
  28. }
  29. }
  30. return false
  31. }

4.相撞的相同颜色泡泡合并为高一级的泡泡

碰撞的两个泡泡,取y座标靠上的一个作为合并的目标,靠下的一个作为源泡泡,合并后的泡泡座标设在目标泡泡座标上。

源泡泡碰撞设为关闭,并设为固定位置;

只实现合并的功能的话,只需要把源泡泡的位置设为目标泡泡的座标就可以,但为了实现动画过渡,源泡泡的位置移动做了如下的处理:

  1. 在每个更新周期计算源泡泡和目标泡泡位置的差值,得到源泡泡需要移动的向量
  2. 移动向量的1/8,在下一个更新周期重复1、2的操作
  3. 当两个泡泡的位置差值小于一个较小的值(这里设为5)时,视为合并完成,销毁源泡泡,并更新目标泡泡的等级信息
  1. // class Physics
  2. mergeBall(srcBody, targetBody, callback) {
  3. const dist = Math.sqrt(getDistSq(srcBody.position, targetBody.position))
  4. // 源泡泡位置设为固定的,且不参与碰撞
  5. Matter.Body.setStatic(srcBody, true)
  6. srcBody.collisionFilter.mask = mergeCategory
  7. // 如果两个泡泡合并到距离小于5的时候, 目标泡泡升级为上一级的泡泡
  8. if(dist < 5) {
  9. // 合并后的泡泡的等级
  10. const newLevel = Math.min(targetBody.level + 1, 8)
  11. const scale = BallRadiusMap[newLevel] / BallRaiusMap[targetBody.level]
  12. // 更新目标泡泡信息
  13. Matter.Body.scale(targetBody, scale, scale)
  14. Matter.Body.set(targetBody, {level: newLevel})
  15. Matter.World.remove(this.matterEngine.world, srcBody)
  16. callback()
  17. return
  18. }
  19. // 需要继续播放泡泡靠近动画
  20. const velovity = {
  21. x: targetBody.position.x - srcBody.position.x,
  22. y: targetBody.position.y - srcBody.position.y
  23. };
  24. // 泡泡移动速度先慢后快
  25. velovity.x /= dist / 8;
  26. velovity.y /= dist / 8;
  27. Matter.Body.translate(srcBody, Matter.Vector.create(velovity.x, velovity.y));
  28. }

因为使用了自定义的方法检测泡泡碰撞,我们需要在物理引擎的beforeUpdate事件上绑定检测碰撞和合并泡泡方法的调用

  1. // class Physics
  2. constructor() {
  3. ...
  4. Matter.Events.on(this.matterEngine, 'beforeUpdate', e => {
  5. // 检查是否有正在合并的泡泡,没有则检测是否有相同颜色的泡泡碰撞
  6. if(!this.collisionInfo) {
  7. this.collisionInfo = this.checkCollision()
  8. }
  9. if(this.collisionInfo) {
  10. // 若有正在合并的泡泡,(继续)调用合并方法,在合并完成后清空属性
  11. this.mergeBall(this.collisionInfo.srcBody, this.collisionInfo.targetBody, () => {
  12. this.collistionInfo = null
  13. })
  14. }
  15. })
  16. ...
  17. }

渲染器模块

GLSL渲染器的实现比较复杂,当前可以先使用matter.js自带的渲染器调试一下。

Physics模块中,再初始化一个matter.jsrender:

  1. class Physics {
  2. constructor(...) {
  3. ...
  4. this.render = Matter.Render.create(...)
  5. Matter.Render.run(this.render)
  6. }
  7. }

开发定制渲染器

接下来该说一下渲染器的实现了。

先说一下这种像是两滴液体靠近,边缘合并的效果是怎么实现的。

如果我们把眼镜脱下,或焦点放远一点,大概可以看到这样的图像:

看到这里可能就有人猜到是怎样实现的了。

是的,就是利用两个边缘径向渐变亮度的圆形,在它们的渐变边缘叠加的位置,亮度的相加能达到圆形中心的程度。

然后在这个渐变边缘的图形上加一个阶跃函数滤镜(低于某个值置为0,高于则置1),就可以得出第一张图的效果。

着色器结构

因为泡泡的数量是一直变化的,而片段着色器fragmentShaderfor循环判断条件(如i < length)必须是和常量作判断,(即length必须是常量)。

所以这里把泡泡座标作为顶点座标传入顶点着色器vertexShader,初步渲染泡泡轮廓:

  1. // 顶点着色器 vertexShader
  2. attribute vec2 a_Position;
  3. attribute float a_PointSize;
  4. void main() {
  5. gl_Position = vec4(a_Position, 0.0, 1.0);
  6. gl_PointSize = a_PointSize;
  7. }
  1. // 片段着色器 fragmentShader
  2. #ifdef GL_ES
  3. precision mediump float;
  4. #endif
  5. void main() {
  6. float d = length(gl_PointCoord - vec2(0.5, 0.5));
  7. float c = smoothstep(0.40, 0.20, d);
  8. gl_FragColor = vec4(vec3(c), 1.0);
  9. }
  1. // 渲染器 Renderer.js
  2. class GLRenderer {
  3. ...
  4. // 更新游戏元素数据
  5. updateData(posData, sizeData) {
  6. ...
  7. this.posData = new Float32Array(posData)
  8. this.sizeData = new Float32Array(sizeData)
  9. ...
  10. }
  11. // 更新渲染
  12. draw() {
  13. ...
  14. // 每个顶点取2个数
  15. this.setAttribute(this.program, 'a_Position', this.posData, 2, 'FLOAT')
  16. // 每个顶点取1个数
  17. this.setAttribute(this.program, 'a_PointSize', this.sizeData, 1, 'FLOAT')
  18. ...
  19. }
  20. }

渲染器的js代码中,把每个点的x,y座标合并成一个一维数组,传到着色器的a_Position属性;把每个点的直径同样组成一个数组,传到着色器的a_PointSize属性。

再调用WebGLdrawArray(gl.POINTS)方法画点,使每个泡泡渲染成一个顶点。

顶点默认渲染成一个方块,所以我们在片段着色器中,取顶点渲染范围的座标(内置属性)gl_PointCoord到顶点中心点(vec2(0.5, 0.5))距离画边缘亮度径向渐变的圆。

如下图,我们应该能得到每个泡泡都渲染成灯泡一样的效果:

注意这里的WebGL上下文需要指定混合像素算法,否则每个顶点的范围会覆盖原有的图像,观感上为每个泡泡带有一个方形的边框

  1. gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
  2. gl.enable(gl.BLEND);

如上文所说的,我们还需要给这个图像加一个阶跃函数滤镜;但我们不能在上面的片段着色器上直接采用阶跃函数处理输出,因为它是对每个顶点独立渲染的,不会带有其他顶点在当前顶点范围内的信息,也就不会有前面说的「亮度相加」的计算可能。

一个思路是将上面着色器的渲染图像作为一个纹理,在另一套着色器上做阶跃函数处理,作最后实际输出。

对于这样的多级处理,WebGL建议使用FrameBuffer容器,把渲染结果绘制在上面;整个完整的渲染流程如下:

泡泡绘制 --> frameBuffer --> texture --> 阶跃函数滤镜 --> canvas

使用frameBuffer的方法如下:

  1. // 创建frameBuffer
  2. var frameBuffer = gl.createFramebuffer()
  3. // 创建纹理texture
  4. var texture = gl.createTexture()
  5. // 绑定纹理到二维纹理
  6. gl.bindTexture(gl.TEXTURE_2D, texture)
  7. // 设置纹理信息,注意宽度和高度需是2的次方幂,纹理像素来源为空
  8. gl.texImage2D(
  9. gl.TEXTURE_2D,
  10. 0,
  11. gl.RGBA,
  12. 1024,
  13. 1024,
  14. 0,
  15. gl.RGBA,
  16. gl.UNSIGNED_BYTE,
  17. null
  18. )
  19. // 设置纹理缩小滤波器
  20. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  21. // frameBuffer与纹理绑定
  22. gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)

使用以下方法,指定frameBuffer为渲染目标:

  1. gl.bindFramebuffer(gl.FRAMEBUFFER, frameBuffer)

frameBuffer绘制完成,将自动存储到0号纹理中,供第二次的着色器渲染使用

  1. // 场景顶点着色器 SceneVertexShader
  2. attribute vec2 a_Position;
  3. attribute vec2 a_texcoord;
  4. varying vec2 v_texcoord;
  5. void main() {
  6. gl_Position = vec4(a_Position, 0.0, 1.0);
  7. v_texcoord = a_texcoord;
  8. }
  1. // 场景片段着色器 SceneFragmentShader
  2. #ifdef GL_ES
  3. precision mediump float;
  4. #endif
  5. varying vec2 v_texcoord;
  6. uniform sampler2D u_sceneMap;
  7. void main() {
  8. vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
  9. d = smoothstep(0.6, 0.7, mapColor.r);
  10. gl_FragColor = vec4(vec3(d), 1.0);
  11. }

场景着色器输入3个参数,分别是:

  1. a_Position: 纹理渲染的面的顶点座标,因为这里的纹理是铺满全画布,所以是画布的四个角
  2. a_textcoord: 各个顶点的纹理uv座标,因为纹理大小和渲染大小不一样(纹理大小为1024*1024,渲染大小为画布大小),所以是从(0.0, 0.0)(width / 1024, height / 1024)
  3. u_sceneMap: 纹理序号,用的第一个纹理,传入0
  1. // 渲染器 Renderer.js
  2. class Renderer {
  3. ...
  4. drawScene() {
  5. // 把渲染目标设回画布
  6. gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  7. // 使用渲染场景的程序
  8. gl.useProgram(sceneProgram);
  9. // 设置4个顶点座标
  10. this.setAttribute(this.sceneProgram, "a_Position", new Float32Array([
  11. -1.0,
  12. -1.0,
  13. 1.0,
  14. -1.0,
  15. -1.0,
  16. 1.0,
  17. -1.0,
  18. 1.0,
  19. 1.0,
  20. -1.0,
  21. 1.0,
  22. 1.0
  23. ]), 2, "FLOAT");
  24. // 设置顶点座标的纹理uv座标
  25. setAttribute(sceneProgram, "a_texcoord", new Float32Array([
  26. 0.0,
  27. 0.0,
  28. canvas.width / MAPSIZE,
  29. 0.0,
  30. 0.0,
  31. canvas.height / MAPSIZE,
  32. 0.0,
  33. canvas.height / MAPSIZE,
  34. canvas.width / MAPSIZE,
  35. 0.0,
  36. canvas.width / MAPSIZE,
  37. canvas.height / MAPSIZE
  38. ]), 2, "FLOAT");
  39. // 设置使用0号纹理
  40. this.setUniform1i(this.sceneProgram, 'u_sceneMap', 0);
  41. // 用画三角形面的方法绘制
  42. this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
  43. }
  44. }

不同类型的泡泡区别

在上一节中,实现了游戏里不同位置、不同大小的泡泡在画布上的绘制,也实现了泡泡之间粘合的效果,但是所有的泡泡都是一样的颜色,而且不能合并的泡泡之间也有粘合的效果,这不是我们想要的效果;

在这一节,我们把这些不同类型泡泡做出区别。

要区分各种类型的泡泡,可以在第一套着色器中只传入某个类型的泡泡信息,重复绘制出纹理供第二套场景着色器使用。但每次只绘制一个类型的泡泡会增加很多的绘制次数。

其实在上一节的场景着色器中,只使用了红色通道,而绿色、蓝色通道的值和红色是一样的:

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

其实我们可以在rgb3个通道中传入不同类型的泡泡数据(alpha通道的值若为0时,rgb通道的值与设定的不一样,所以不能使用),这样在一个绘制过程中可以绘制3个类型的泡泡;泡泡的类型共有8种,需要分3组渲染。我们在第一套着色器绘制泡泡的时候,增加传入绘制组别和泡泡等级的数据。

并在顶点着色器和片段着色器间增加一个varying类型数据,指定该泡泡使用哪一个rgb通道。

  1. // 修改后的顶点着色器 vertexShader
  2. uniform int group;// 绘制的组序号
  3. attribute vec2 a_Position;
  4. attribute float a_Level;// 泡泡的等级
  5. attribute float a_PointSize;
  6. varying vec4 v_Color;// 片段着色器该使用哪个rgb通道
  7. void main() {
  8. gl_Position = vec4(a_Position, 0.0, 1.0);
  9. gl_PointSize = a_PointSize;
  10. if(group == 0){
  11. if(a_Level == 1.0){
  12. v_Color = vec4(1.0, 0.0, 0.0, 1.0);// 使用r通道
  13. }
  14. if(a_Level == 2.0){
  15. v_Color = vec4(0.0, 1.0, 0.0, 1.0);// 使用g通道
  16. }
  17. if(a_Level == 3.0){
  18. v_Color = vec4(0.0, 0.0, 1.0, 1.0);// 使用b通道
  19. }
  20. }
  21. if(group == 1){
  22. if(a_Level == 4.0){
  23. v_Color = vec4(1.0, 0.0, 0.0, 1.0);
  24. }
  25. if(a_Level == 5.0){
  26. v_Color = vec4(0.0, 1.0, 0.0, 1.0);
  27. }
  28. if(a_Level == 6.0){
  29. v_Color = vec4(0.0, 0.0, 1.0, 1.0);
  30. }
  31. }
  32. if(group == 2){
  33. if(a_Level == 7.0){
  34. v_Color = vec4(1.0, 0.0, 0.0, 1.0);
  35. }
  36. if(a_Level == 8.0){
  37. v_Color = vec4(0.0, 1.0, 0.0, 1.0);
  38. }
  39. if(a_Level == 9.0){
  40. v_Color = vec4(0.0, 0.0, 1.0, 1.0);
  41. }
  42. }
  43. }
  1. // 修改后的片段着色器 fragmentShader
  2. #ifdef GL_ES
  3. precision mediump float;
  4. #endif
  5. varying vec4 v_Color;
  6. void main(){
  7. float d = length(gl_PointCoord - vec2(0.5, 0.5));
  8. float c = smoothstep(0.40, 0.20, d);
  9. gl_FragColor = v_Color * c;
  10. }

场景片段着色器分别对3个通道作阶跃函数处理(顶点着色器不变),同样传入绘制组序号,区别不同类型的泡泡颜色:

  1. // 修改后的场景片段着色器
  2. #ifdef GL_ES
  3. precision mediump float;
  4. #endif
  5. varying vec2 v_texcoord;
  6. uniform sampler2D u_sceneMap;
  7. uniform vec2 u_resolution;
  8. uniform int group;
  9. void main(){
  10. vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
  11. float d = 0.0;
  12. vec4 color = vec4(0.0);
  13. if(group == 0){
  14. if(mapColor.r > 0.0){
  15. d = smoothstep(0.6, 0.7, mapColor.r);
  16. color += vec4(0.86, 0.20, 0.18, 1.0) * d;
  17. }
  18. if(mapColor.g > 0.0){
  19. d = smoothstep(0.6, 0.7, mapColor.g);
  20. color += vec4(0.80, 0.29, 0.09, 1.0) * d;
  21. }
  22. if(mapColor.b > 0.0){
  23. d = smoothstep(0.6, 0.7, mapColor.b);
  24. color += vec4(0.71, 0.54, 0.00, 1.0) * d;
  25. }
  26. }
  27. if(group == 1){
  28. if(mapColor.r > 0.0){
  29. d = smoothstep(0.6, 0.7, mapColor.r);
  30. color += vec4(0.52, 0.60, 0.00, 1.0) * d;
  31. }
  32. if(mapColor.g > 0.0){
  33. d = smoothstep(0.6, 0.7, mapColor.g);
  34. color += vec4(0.16, 0.63, 0.60, 1.0) * d;
  35. }
  36. if(mapColor.b > 0.0){
  37. d = smoothstep(0.6, 0.7, mapColor.b);
  38. color += vec4(0.15, 0.55, 0.82, 1.0) * d;
  39. }
  40. }
  41. if(group == 2){
  42. if(mapColor.r > 0.0){
  43. d = smoothstep(0.6, 0.7, mapColor.r);
  44. color += vec4(0.42, 0.44, 0.77, 1.0) * d;
  45. }
  46. if(mapColor.g > 0.0){
  47. d = smoothstep(0.6, 0.7, mapColor.g);
  48. color += vec4(0.83, 0.21, 0.51, 1.0) * d;
  49. }
  50. if(mapColor.b > 0.0){
  51. d = smoothstep(0.6, 0.7, mapColor.b);
  52. color += vec4(1.0, 1.0, 1.0, 1.0) * d;
  53. }
  54. }
  55. gl_FragColor = color;
  56. }

这里使用了分多次绘制成3个纹理图像,处理后合并成最后的渲染图像,场景着色器绘制了3次,这需要在每次绘制保留上次的绘制结果;而默认的WebGL绘制流程,会在每次绘制时清空图像,这需要修改这个默认流程:

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

经过以上处理,整个游戏已基本完成,在这以上可以再修改泡泡的样式、添加分数展示等的部分。

完整项目源码可以访问: https://github.com/wenxiongid/bubble

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

WebGL着色器渲染小游戏实战的更多相关文章

  1. WebGL 着色器偏导数dFdx和dFdy介绍

    本文适合对webgl.计算机图形学.前端可视化感兴趣的读者. 偏导数函数(HLSL中的ddx和ddy,GLSL中的dFdx和dFdy)是片元着色器中的一个用于计算任何变量基于屏幕空间坐标的变化率的指令 ...

  2. WebGL着色器32位浮点数精度损失问题

    问题 WebGL浮点数精度最大的问题是就是因为js是64位精度的,js往着色器里面穿的时候只能是32位浮点数,有效数是8位,精度丢失比较严重. 这篇文章里讲了一些处理方式,但是视坐标这种方式放在我们的 ...

  3. OpenGL笔记(五) 着色器渲染(以Android为例)

    一.Android平台上下文环境的创建及初始化 1. 首先实例化Android上下文环境,即EGL的初始化. bool EGLCore::init(EGLContext sharedContext) ...

  4. WebGL 着色器语言(GLSL ES)

    1.类型转换内置函数 转换/函数/描述 转换为整形数/int(float)/将浮点数的小数部分删去,转换为整形数(比如,将3.14转换为3) 转换为整形数/intl(bool)/true被转换为1,f ...

  5. createjs 小游戏开发实战

    [转载请注明出处] 紧接着上一篇文章createjs入门:http://www.cnblogs.com/beidan/p/7055422.html 这里来一篇小游戏实战篇. 游戏整体思路实现 1. 实 ...

  6. OpenGl中使用着色器的基本步骤及GLSL渲染简单示例

    OpenGL着色语言(OpenGL Shading Language,GLSL)是用来在OpenGL中着色编程的语言,是一种具有C/C++风格的高级过程语言,同样也以main函数开始,只不过执行过程是 ...

  7. [整理]IE11中的WebGL探秘:渲染速度超Chrome

    http://www.csdn.net/article/2013-12-19/2817854-IE11-WebGL-and-more 摘要:IE11开始支持WebGL,并且效果非常好,IE11的Web ...

  8. Unity Shader着色器优化

    https://mp.weixin.qq.com/s?__biz=MzU5MjQ1NTEwOA==&mid=2247493518&idx=1&sn=c51b92e9300bcf ...

  9. [SpriteKit] 系统框架中Cocos2d-x制作小游戏ZombieConga

    概述 使用SpriteKit实现一个简单的游戏, 通过一个游戏来进行SpriteKit的入门, 熟练2D游戏的API, 也可以更好的结合在iOS应用中. 详细 代码下载:http://www.demo ...

随机推荐

  1. SpringBoot异步使用@Async原理及线程池配置

    前言 在实际项目开发中很多业务场景需要使用异步去完成,比如消息通知,日志记录,等非常常用的都可以通过异步去执行,提高效率,那么在Spring框架中应该如何去使用异步呢 使用步骤 完成异步操作一般有两种 ...

  2. Devexpress gridcontrol设置列样式

    <dxg:GridControl.Columns><dxg:GridColumn Header="排名" FieldName="UserRank&quo ...

  3. 【转】shell中的$0 $n $# $* $@ $? $$ 变量 if case for while

    shell中的$0 $n $# $* $@ $? $$  shell 编程 | shift 命令用法笔记 $0当前脚本的文件名 $n传递给脚本或函数的参数.n 是一个数字,表示第几个参数.例如,第一个 ...

  4. 通过Kubernetes监控探索应用架构,发现预期外的流量

    大家好,我是阿里云云原生应用平台的炎寻,很高兴能和大家一起在 Kubernetes 监控系列公开课上进行交流.本次公开课期望能够给大家在 Kubernetes 容器化环境中快速发现和定位问题带来新的解 ...

  5. video.js视频播放插件

    1 初始化 Video.js初始化有两种方式. 1.1 标签方式 一种是在<video>标签里面加上class="video-js"和data-setup='{}'属性 ...

  6. jquery获取一个元素符合条件的第一个父元素

    closest jQuery 1.3新增.从元素本身开始,逐级向上级元素匹配,并返回最先匹配的元素.. closest会首先检查当前元素是否匹配,如果匹配则直接返回元素本身.如果不匹配则向上查找父元素 ...

  7. php设计模式--生成器模式

    生成器模式 require "D:\\xxx\bild.php"; require "D:\\xxx\cx_bild.php"; require "D ...

  8. 测试验收标准checklist

    需求实现功能清单 功能实现目的 需求改造功能清单 关联功能清单 关联系统 端到端全流程场景 业务联系性场景 业务全流程场景 上下需求关联规则 业务角度在流程中关注项 财报.评级 授信方案 反洗钱 面向 ...

  9. linux 客户机挂载vitualbox共享文件夹

    1. 安装增强功能包(Guest Additions) 安装好Ubuntu 9.10后,运行Ubuntu并登录.然后在VirtualBox的菜单里选择"设备(Devices)" - ...

  10. P5644-[PKUWC2018]猎人杀【NTT,分治】

    正题 题目链接:https://www.luogu.com.cn/problem/P5644 题目大意 \(n\)个人,每个人被选中的权重是\(a_i\).每次按照权重选择一个没有死掉的人杀死,求第\ ...