2019双十一拼图游戏 WebGL 揭秘

2,857 阅读4分钟

前言

WebGL 应用已经比较成熟,在网络中也能找到很多精彩的应用。 本次活动中,应用 WebGL 技术,配合设计师,基于 C4D 软件模型编辑,通过 Blender 进行标准化输出,最终在 Web 端呈现交互。 在此我们一起看一下制作开发流程及很多吸引人的技术细节,以及对于新技术落地应用的主要模式。 主要大纲如下:

  1. 技术方案及学习资料参考
  2. WebGL 技术实现方案
  3. 总结

1. 技术方案及学习资料参考

1.1. 起源

WebGL于 2011年2月 落地于浏览器,最早是 Chrome9 和 Firefox4。 当时,Google Creative Lab 利用 WebGL 技术进行交互展示的页面开发,体现了 WebGL 的强大力量。

ROME 3 DREAMS OF BLACK

现在很多主流浏览器对于 WebGL 也都支持了,并且可以使用相同的体验,台式机,平板,手机,因此,我们可以借助浏览器进行基于 WebGL 的跨平台的开发。

1.2. WebGL 学习曲线

1.3. 游戏参考

1.4. 最终期望效果

2. WebGL 技术实现方案

2.1. 技术选型

2.2. 方案实现

原理概述

2.2.1 初始化

class THREERoot {
  constructor (wrapper, config) {
    // 配置解析
    // ... CONFIG
    
    // 场景初始化
    this.scene = new THREE.Scene()

    const { frustumSize, aspect } = this.config.OrthographicCamera

    // 摄像机初始化
    this.camera = new THREE.OrthographicCamera(
      -frustumSize * aspect / 2,
      frustumSize * aspect / 2,
      frustumSize / 2,
      -frustumSize / 2,
      this.config.OrthographicCamera.near,
      this.config.OrthographicCamera.far
    )
    
    // 渲染引擎初始化
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true
    })

    // 控制器初始化
    this.config.enableControls && 
      (this.controls = new THREE.TrackballControls(this.camera, this.config.controlsDomElement || this.renderer.domElement))
  }
  
  // 关键帧处理
  animate () {
    this.renderer.render(this.scene, this.camera)

    this.config.enableControls && this.controls.update()
    this.camera.updateMatrixWorld()
    this.camera.updateProjectionMatrix()

    this.animateCallback && this.animateCallback()
    this.animateReq = this.requestAnimationFrame.bind(window)(this.animate)
  }
}

const gameInit = () => {
  const { root } = gameConfig
  const {
    scene,
    camera
  } = root
  
  // 游戏背景初始化
  scene.background = new THREE.Color(0xa0a0a0)
  
    
  // 辅助坐标轴
  const axesHelper = new THREE.AxesHelper(5)
  scene.add(axesHelper)
  
  // 相机位置
  camera.up = new THREE.Vector3(0.0, 1.0, 0.0)
  camera.position.z = gameConfig.cameraRadius

  // 相机初始坐标
  camera.userData = {
    standardPosition: camera.position.clone(),
    standardRotation: camera.rotation.clone()
  }
}

// 实例化运行
gameConfig.root = new THREERoot()
gameInit({ gameConfig })
gameConfig.root.animate()

2.2.2 模型和贴图加载

// NOTE Blender 导出模型加载
const loader = new THREE.GLTFLoader()      
loader.load('./assets/demo.glb', function (gltf) {
    const textureLoader = new THREE.TextureLoader()
    // texture 贴图加载,模型加载完成后可以预运行
    const texture = textureLoader.load('./assets/demo.png')
    console.log('模型数据:', gltf)
})

2.2.3 模型数据解析展示

gltf.scene.traverse(function (child) {
  if (child.isMesh) {
    const positions = child.geometry.attributes.position.array
    const normals = child.geometry.attributes.normal.array
    const uvs = child.geometry.attributes.uv.array
    const indexs = child.geometry.index.array

    const group = new THREE.Group()

    for (let i = 0, l = indexs.length; i < l; i += 3) {
      // 点坐标索引
      const points = [
        [indexs[i + 0] * 3, indexs[i + 0] * 3 + 1, indexs[i + 0] * 3 + 2],
        [indexs[i + 1] * 3, indexs[i + 1] * 3 + 1, indexs[i + 1] * 3 + 2],
        [indexs[i + 2] * 3, indexs[i + 2] * 3 + 1, indexs[i + 2] * 3 + 2]
      ]
      // 贴图坐标索引
      const coordinates = [
        [indexs[i + 0] * 2, indexs[i + 0] * 2 + 1],
        [indexs[i + 1] * 2, indexs[i + 1] * 2 + 1],
        [indexs[i + 2] * 2, indexs[i + 2] * 2 + 1]
      ]
      
      // 点坐标
      const positionsTraverse = new Float32Array([
        positions[ points[0][0] ], positions[ points[0][1] ], positions[ points[0][2] ],
        positions[ points[1][0] ], positions[ points[1][1] ], positions[ points[1][2] ],
        positions[ points[2][0] ], positions[ points[2][1] ], positions[ points[2][2] ]
      ])
      // 法线坐标
      const normalsTraverse = new Float32Array([
        normals[ points[0][0] ], normals[ points[0][1] ], normals[ points[0][2] ],
        normals[ points[1][0] ], normals[ points[1][1] ], normals[ points[1][2] ],
        normals[ points[2][0] ], normals[ points[2][1] ], normals[ points[2][2] ]
      ])
      // uv贴图坐标
      const uvsTraverse = new Float32Array([
        uvs[ coordinates[0][0] ], uvs[ coordinates[0][1] ],
        uvs[ coordinates[1][0] ], uvs[ coordinates[1][1] ],
        uvs[ coordinates[2][0] ], uvs[ coordinates[2][1] ]
      ])

      // 顶点着色缓冲对象
      const geometry = new THREE.BufferGeometry()
      geometry.setAttribute('position', new THREE.BufferAttribute(positionsTraverse, 3))
      geometry.setAttribute('normal', new THREE.BufferAttribute(normalsTraverse, 3))
      geometry.setAttribute('uv', new THREE.BufferAttribute(uvsTraverse, 2))
      geometry.setIndex([0, 1, 2])

      // 材质对象
      const material = new THREE.MeshBasicMaterial( { wireframe: true, color: 0xffaa00 } )
      material.side = THREE.DoubleSide
      const mesh = new THREE.Mesh(geometry, material)
      // NOTE 这里 的 1 / 100  比例缩放是 blender 导出之后的参数修正
      // 相对于 c4d 来说的话,scale 是 1
      mesh.scale.set(gameConfig.scale, gameConfig.scale, gameConfig.scale)

      group.add(mesh)
    }
    group.name = gameConfig.groupName
    scene.add(group)
  }
})

2.2.4 球体坐标变换

// NOTE 这里先创建球体进行坐标变换
const phi = Math.acos(-1 + (2 * i) / l)
const theta = Math.sqrt(l * Math.PI) * phi
const meshGroup = new THREE.Group()
meshGroup.name = gameConfig.meshGroupName
meshGroup.position.setFromSphericalCoords(2, phi, theta)

meshGroup.add(mesh)
group.add(meshGroup)

2.2.5 更改每个 mesh 旋转中心并且面向 camera

// NOTE 更改每个三角面的中心点
geometry.computeBoundingBox()
var center = new THREE.Vector3()
geometry.boundingBox.getCenter(center)
geometry.center()
mesh.position.copy(center)
mesh.position.multiplyScalar(gameConfig.scale)

mesh.geometry.lookAt(camera.position)
root.animateCallback = () => {
    if (child.isMesh) {
      if (!child.children.length) {
        child.lookAt(camera.position)
      }
    }
  })
}

2.2.6 回归原来位置并添加纹理

// Mesh 回归原来位置
mesh.position.x -= meshGroup.position.x
mesh.position.y -= meshGroup.position.y
mesh.position.z -= meshGroup.position.z

mesh.geometry.lookAt(camera.position)
// 纹理顶点着色
const textureVertex = `
  #ifdef GL_ES
  precision mediump float;
  #endif
  // attribute float size;
  varying vec2 vUv;

  void main() {
    vUv = uv;

    vec4 modelViewPosition = modelViewMatrix * vec4(position, 1.0);
    gl_Position = projectionMatrix * modelViewPosition;
  }
`
// 纹理片元着色
const textureFragment = `
  #ifdef GL_ES
  precision mediump float;
  #endif

  uniform vec2 u_resolution;
  uniform float u_time;
  uniform vec3 u_position;
  uniform float instensity;

  varying vec2 vUv;

  // u_texture
  uniform sampler2D u_texture;

  void main (void) {
    vec2 uv = gl_FragCoord.xy / u_resolution;

    vec4 textureColor = texture2D(u_texture, vUv);

    gl_FragColor = vec4(textureColor.rgb * instensity, textureColor.a);
  }
`

// Material 创建
const material = new THREE.ShaderMaterial({
  uniforms: {
    u_texture: {
      type: 'sampler2D',
      value: texture
    },
    u_resolution: new THREE.Uniform(new THREE.Vector2()),
    instensity: { type: 'f', value: 1.0 }
  },
  fragmentShader: textureFragment,
  vertexShader: textureVertex
})

2.2.7 添加摄像机变换及随机交互参数

// 每个 Mesh 配置随机偏移参数
mesh.userData = {
    rotationRandom: Math.random() * 2 - 1,
    positionRandom: Math.random() - 0.5,
    rotation: camera.rotation.clone()
}
// 每个 Mesh 进行随机位置偏移
const {
    rotationRandom,
    positionRandom,
    rotation
} = child.userData

if (!child.children.length) {
    // NOTE blender 导出模型 gltf2.0 选项一定要勾选 +Y up
    child.rotation.z = (rotation.x - Math.sin(x)) * rotationRandom * rotationOffset
    child.position.z = gameConfig.positionOffset * positionRandom
    child.lookAt(camera.position)
}

3. 总结

在完成开发步骤之后,可使用一些动画库对 Mesh 进行序列帧动画的添加和调试。最终效果如下:

感谢一起工作的前端同事李战帮助一起进行工程化开发及项目总结的支持,也感谢其他合作方在开发过程中的支持和配合。