threejs游戏开发经验总结

背景

2020年6月21日,中国大部分地区可观测到日环食现象,头条热点联合新华社开展了一个日环食H5活动,以游戏的形式展开,附带直播、资讯等内容分发。我负责本次运营活动的 H5 游戏部分开发。

体验入口(仅适配移动端):

  • 今日头条端内搜索 “日环食大挑战”

  • 链接直接打开:日环食大挑战

实现基于 threejs, 以下是游戏开始界面。

游戏界面

three.js躺坑经验

在这次活动的开发中,由于是第一次接触 three.js,过程中踩了不少坑,总结了一些经验供他人参考(坑这种东西,有一个人躺过就行了)

对于从未接触过 three.js 的同学,建议先照着 起步文档 先写一个小 demo,这有助于你对 three.js 的一些核心概念:场景、相机、物体、材质有一个初步的认识。在对核心概念有一定了解之后,就可以着手尝试需求中的一些效果,多写写 demo,提前把坑躺了,降低实际开发时的风险。

以下是一些我在开发过程中躺过的坑(有些没掉进去,但是总感觉会有年轻的小朋友躺进去,也列出来了):

场景初始化

新手接触 three.js 很容易遇到的问题是,我已经把场景、模型、相机什么的都写好了,为什么打开是一团黑?问题可能存在以下几点:

  1. 光源问题

有几种光源问题会导致物体看不到的情况:

  • three.js 中物体分为很多材质,同时可以给物体贴图,当场景中没有光源投射到物体上时会看不到贴图。实际上物体是在的,但是因为没有光源,所以物体是黑的。

  • 当场景中只有指向类光源(如平行光、聚光灯等),物体不在光源的覆盖范围时,物体同样是黑的。

建议:初始化场景时第一步先添加一个环境光,不需要的话后续再去掉即可

  1. 相机朝向位置或相机位置不对

相机(有不同类型,自行查看官网文档)初始化完成之后,默认朝向坐标和相机位置都是 (0, 0, 0),即原点位置(我盯我自己)。

一般而言初始化时都会设置一个相机位置,存在以下情况时会看不见物体:

  • 物体不在相机的朝向位置

  • 相机在物体内部

建议:

  1. 初始化时引入 control 插件,可以自行缩放、旋转相机角度,方便调试
  2. 物体设置不宜过大或过小(相对于物体和相机的距离而言)

反复调整问题

这个是前端老大难的问题,实际实现总是和设计、动效的预期偏离,需要不断的调整,简单的 CSS 还好,但是 3D 页面的反复调整非常耗时间。对于有可能反复调整的参数,可以借助 three.js 内置的 GUI 工具生成可视化面板去调整,例如在调整太阳辉光(UnrealBloomPass)时,利用 GUI 提供一个参数调整面板,让设计师自行调整后给到对应的参数值:

import * as dat from 'three/examples/jsm/libs/dat.gui.module';
const params = {
  threshold: 0.4,
  strength: 2,
  radius: 1,
};
const gui = new dat.GUI();
gui.add(params, 'threshold', 0.0, 1.0).step(0.01).onChange(function (value) {
 bloomPass.threshold = Number(value);
});
gui.add(params, 'strength', 0.0, 10.0).onChange(function (value) {
 bloomPass.strength = Number(value);
});
gui.add(params, 'radius', 0.0, 2.0)
  .step(0.01)
  .onChange(function (value) {
    bloomPass.radius = Number(value);
  });

界面上会生成一个可视化的调整面板

可视化面板示例

物体模型构建

日环食活动中主要的物体模型有三个,太阳、地球和月球。以下是太阳的生成代码,相关的 geometry、material 自行查看官网文档,这里不赘述。

import {
  TextureLoader,
  SphereGeometry,
  Mesh,
  MeshBasicMaterial,
} from 'three';
const data = {
  radius: 100,
  textureUrl: '<贴图链接>',
};
export function loadSunAsync () {
  const loader = new TextureLoader();
  return new Promise(resolve => {
    loader.load(data.textureUrl, function (texture) {
      // 构建球体
      const geometry = new SphereGeometry(data.radius, 90, 90);
      // 构建贴图材质
      const material = new MeshBasicMaterial({
        map: texture,
      });
      // 生成模型
      const sun = new Mesh(geometry, material);
      resolve(sun);
    });
  });
}

除开这种实现方式,还有其他方式生成 3D 模型,最简单快捷的方式是由设计提供可用的格式文件,如 gltf 格式,文件中直接包含模型形状、大小、贴图、位置等各种信息(可以多个模型)。

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';

const loader = new THREE.GLTFLoader();
loader.load('xxxx.gltf', function (gltf) {
  scene.add(gltf.scene);
}, undefined, function (error) {
  console.error(error);
});

自行实现

  • 优点
    • 模型可控性高
    • 资源体积小
    • 方便调整
  • 缺点
    • 需要实现的细节较多
    • 复杂模型开发难度大

文件导入

  • 优点
    • 方便快捷
    • 开发成本小
    • 支持复杂模型
  • 缺点
    • 可控性低
    • 资源体积大
    • 不方便调整

由于这次活动中三个物体都是简单的球体,且需要进行各自的动效、样式变更,对可控性要求很高,同时考虑到资源体积大小问题,采用了自行实现的方式。

辉光效果

这个算是躺了最久的一个坑,主要的问题有几个:

  • 辉光如何实现

  • 单独给物体加辉光

  • 辉光效果导致背景变黑

辉光如何实现

通过 three.js 的 UnrealBloomPass 实现

import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';

const params = {
  threshold: 0.4,
  strength: 2,
  radius: 1,
};

const renderScene = new RenderPass(scene, camera);

const bloomPass = new UnrealBloomPass(new Vector2(window.innerWidth, window.innerHeight), params.strength, params.radius, params.threshold);
bloomPass.renderToScreen = true;
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;

const composer = new EffectComposer(renderer);
composer.setSize(window.innerWidth, window.innerHeight);
composer.addPass(renderScene);
composer.addPass(bloomPass);

单独给某个物体加辉光

本次方案中采用分层渲染实现,以下是主要代码(有删减)

const renderer = new WebGLRenderer({
  alpha: true,
  antialias: true,
});
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const params = {
  threshold: 0.4,
  strength: 2,
  radius: 1,
};

// 只有太阳需要加辉光
const sunScene = new Scene();
const renderScene = new RenderPass(sunScene, camera);

bloomPass = new UnrealBloomPass(new Vector2(window.innerWidth, window.innerHeight), params.strength, params.radius, params.threshold);
bloomPass.renderToScreen = true;

const composer = new EffectComposer(renderer);
composer.setSize(window.innerWidth, window.innerHeight);
composer.addPass(renderScene);
composer.addPass(bloomPass);

const [sun, earth, moon] = await Promise.all([
  loadSunAsync(),
  loadEarthAsync(),
  loadMoonAsync(),
]);

sun.layers.enable(1);
sunScene.add(sun);

scene.add(earth);
scene.add(moon);

function animate () {
  requestAnimationFrame(animate);

  // 先绘制辉光层
  renderer.clear();
  camera.layers.set(1);
  composer.render();

  // 绘制正常层
  renderer.clearDepth();
  camera.layers.set(0);
  renderer.render(scene, camera);
}
animate();

另外还有基于 MaskPass 和 clearMask 的实现方案,具体可以看这篇文章 three.js 机房 demo

辉光导致背景变黑(影响透明度)

这个是 three.js 的一个 bug:issue 14104。 辉光效果的实现原理是对当前显示的内容添加一个滤镜,官网给的滤镜在曝光度 threshold 不为0时,会更改画面的透明度,导致通过设置 canvas 透明来显示的背景无法显示。

解决方案:

// 这是一个重写过的 UnrealBloomPass 滤镜,直接用其替代官方提供的 UnrealBloomPass 即可
import {
  AdditiveBlending,
  Vector2,
  Vector3,
  Color,
  LinearFilter,
  MeshBasicMaterial,
  RGBAFormat,
  WebGLRenderTarget,
  UniformsUtils,
  ShaderMaterial,
} from 'three';
import { Pass } from 'three/examples/jsm/postprocessing/Pass';
import { LuminosityHighPassShader } from 'three/examples/jsm/shaders/LuminosityHighPassShader';
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader';

export function UnrealBloomPass (resolution, strength, radius, threshold) {
  Pass.call(this);
  this.strength = strength !== undefined ? strength : 1;
  this.radius = radius;
  this.threshold = threshold;
  this.resolution = resolution !== undefined
    ? new Vector2(resolution.x, resolution.y)
    : new Vector2(256, 256);
  // create color only once here, reuse it later inside the render function
  this.clearColor = new Color(0, 0, 0);
  // render targets
  var pars = {
    minFilter: LinearFilter,
    magFilter: LinearFilter,
    format: RGBAFormat,
  };
  this.renderTargetsHorizontal = [];
  this.renderTargetsVertical = [];
  this.nMips = 5;
  var resx = Math.round(this.resolution.x / 2);
  var resy = Math.round(this.resolution.y / 2);
  this.renderTargetBright = new WebGLRenderTarget(resx, resy, pars);
  this.renderTargetBright.texture.name = 'UnrealBloomPass.bright';
  this.renderTargetBright.texture.generateMipmaps = false;
  for (var i = 0; i < this.nMips; i++) {
    var renderTargetHorizonal = new WebGLRenderTarget(resx, resy, pars);
    renderTargetHorizonal.texture.name = 'UnrealBloomPass.h' + i;
    renderTargetHorizonal.texture.generateMipmaps = false;
    this.renderTargetsHorizontal.push(renderTargetHorizonal);
    var renderTargetVertical = new WebGLRenderTarget(resx, resy, pars);
    renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i;
    renderTargetVertical.texture.generateMipmaps = false;
    this.renderTargetsVertical.push(renderTargetVertical);
    resx = Math.round(resx / 2);
    resy = Math.round(resy / 2);
  }
  // luminosity high pass material
  if (LuminosityHighPassShader === undefined) {
    console.error(
      'UnrealBloomPass relies on LuminosityHighPassShader'
    );
  }
  var highPassShader = LuminosityHighPassShader;
  this.highPassUniforms = UniformsUtils.clone(highPassShader.uniforms);
  this.highPassUniforms['luminosityThreshold'].value = threshold;
  this.highPassUniforms['smoothWidth'].value = 0.01;
  this.materialHighPassFilter = new ShaderMaterial({
    uniforms: this.highPassUniforms,
    vertexShader: highPassShader.vertexShader,
    fragmentShader: highPassShader.fragmentShader,
    defines: {},
  });
  // Gaussian Blur Materials
  this.separableBlurMaterials = [];
  var kernelSizeArray = [3, 5, 7, 9, 11];
  resx = Math.round(this.resolution.x / 2);
  resy = Math.round(this.resolution.y / 2);
  for (i = 0; i < this.nMips; i++) {
    this.separableBlurMaterials.push(
      this.getSeperableBlurMaterial(kernelSizeArray[i])
    );
    this.separableBlurMaterials[i].uniforms[
      'texSize'
    ].value = new Vector2(resx, resy);
    resx = Math.round(resx / 2);
    resy = Math.round(resy / 2);
  }
  // Composite material
  this.compositeMaterial = this.getCompositeMaterial(this.nMips);
  this.compositeMaterial.uniforms[
    'blurTexture1'
  ].value = this.renderTargetsVertical[0].texture;
  this.compositeMaterial.uniforms[
    'blurTexture2'
  ].value = this.renderTargetsVertical[1].texture;
  this.compositeMaterial.uniforms[
    'blurTexture3'
  ].value = this.renderTargetsVertical[2].texture;
  this.compositeMaterial.uniforms[
    'blurTexture4'
  ].value = this.renderTargetsVertical[3].texture;
  this.compositeMaterial.uniforms[
    'blurTexture5'
  ].value = this.renderTargetsVertical[4].texture;
  this.compositeMaterial.uniforms['bloomStrength'].value = strength;
  this.compositeMaterial.uniforms['bloomRadius'].value = 0.1;
  this.compositeMaterial.needsUpdate = true;
  var bloomFactors = [1.0, 0.8, 0.6, 0.4, 0.2];
  this.compositeMaterial.uniforms['bloomFactors'].value = bloomFactors;
  this.bloomTintColors = [
    new Vector3(1, 1, 1),
    new Vector3(1, 1, 1),
    new Vector3(1, 1, 1),
    new Vector3(1, 1, 1),
    new Vector3(1, 1, 1),
  ];
  this.compositeMaterial.uniforms[
    'bloomTintColors'
  ].value = this.bloomTintColors;
  // copy material
  if (CopyShader === undefined) {
    console.error('BloomPass relies on CopyShader');
  }
  var copyShader = CopyShader;
  this.copyUniforms = UniformsUtils.clone(copyShader.uniforms);
  this.copyUniforms['opacity'].value = 1.0;
  this.materialCopy = new ShaderMaterial({
    uniforms: this.copyUniforms,
    vertexShader: copyShader.vertexShader,
    fragmentShader: copyShader.fragmentShader,
    blending: AdditiveBlending,
    depthTest: false,
    depthWrite: false,
    transparent: true,
  });
  this.enabled = true;
  this.needsSwap = false;
  this.oldClearColor = new Color();
  this.oldClearAlpha = 1;
  this.basic = new MeshBasicMaterial();
  this.fsQuad = new Pass.FullScreenQuad(null);
}
UnrealBloomPass.prototype = Object.assign(
  Object.create(Pass.prototype),
  {
    constructor: UnrealBloomPass,
    dispose: function () {
      let i;
      for (i = 0; i < this.renderTargetsHorizontal.length; i++) {
        this.renderTargetsHorizontal[i].dispose();
      }
      for (i = 0; i < this.renderTargetsVertical.length; i++) {
        this.renderTargetsVertical[i].dispose();
      }
      this.renderTargetBright.dispose();
    },
    setSize: function (width, height) {
      var resx = Math.round(width / 2);
      var resy = Math.round(height / 2);
      this.renderTargetBright.setSize(resx, resy);
      for (var i = 0; i < this.nMips; i++) {
        this.renderTargetsHorizontal[i].setSize(resx, resy);
        this.renderTargetsVertical[i].setSize(resx, resy);
        this.separableBlurMaterials[i].uniforms[
          'texSize'
        ].value = new Vector2(resx, resy);
        resx = Math.round(resx / 2);
        resy = Math.round(resy / 2);
      }
    },
    render: function (
      renderer,
      writeBuffer,
      readBuffer,
      deltaTime,
      maskActive
    ) {
      this.oldClearColor.copy(renderer.getClearColor());
      this.oldClearAlpha = renderer.getClearAlpha();
      var oldAutoClear = renderer.autoClear;
      renderer.autoClear = false;
      renderer.setClearColor(this.clearColor, 0);
      if (maskActive) {
        renderer.context.disable(renderer.context.STENCIL_TEST);
      }
      // Render input to screen
      if (this.renderToScreen) {
        this.fsQuad.material = this.basic;
        this.basic.map = readBuffer.texture;
        renderer.setRenderTarget(null);
        renderer.clear();
        this.fsQuad.render(renderer);
      }
      // 1. Extract Bright Areas
      this.highPassUniforms['tDiffuse'].value = readBuffer.texture;
      this.highPassUniforms['luminosityThreshold'].value = this.threshold;
      this.fsQuad.material = this.materialHighPassFilter;
      renderer.setRenderTarget(this.renderTargetBright);
      renderer.clear();
      this.fsQuad.render(renderer);
      // 2. Blur All the mips progressively
      var inputRenderTarget = this.renderTargetBright;
      for (var i = 0; i < this.nMips; i++) {
        this.fsQuad.material = this.separableBlurMaterials[i];
        this.separableBlurMaterials[i].uniforms['colorTexture'].value = inputRenderTarget.texture;
        this.separableBlurMaterials[i].uniforms['direction'].value = UnrealBloomPass.BlurDirectionX;
        renderer.setRenderTarget(this.renderTargetsHorizontal[i]);
        renderer.clear();
        this.fsQuad.render(renderer);
        this.separableBlurMaterials[i].uniforms[
          'colorTexture'
        ].value = this.renderTargetsHorizontal[i].texture;
        this.separableBlurMaterials[i].uniforms['direction'].value = UnrealBloomPass.BlurDirectionY;
        renderer.setRenderTarget(this.renderTargetsVertical[i]);
        renderer.clear();
        this.fsQuad.render(renderer);
        inputRenderTarget = this.renderTargetsVertical[i];
      }
      // Composite All the mips
      this.fsQuad.material = this.compositeMaterial;
      this.compositeMaterial.uniforms['bloomStrength'].value = this.strength;
      this.compositeMaterial.uniforms['bloomRadius'].value = this.radius;
      this.compositeMaterial.uniforms[
        'bloomTintColors'
      ].value = this.bloomTintColors;
      renderer.setRenderTarget(this.renderTargetsHorizontal[0]);
      renderer.clear();
      this.fsQuad.render(renderer);
      // Blend it additively over the input texture
      this.fsQuad.material = this.materialCopy;
      this.copyUniforms[
        'tDiffuse'
      ].value = this.renderTargetsHorizontal[0].texture;
      if (maskActive) {
        renderer.context.enable(renderer.context.STENCIL_TEST);
      }
      if (this.renderToScreen) {
        renderer.setRenderTarget(null);
        this.fsQuad.render(renderer);
      } else {
        renderer.setRenderTarget(readBuffer);
        this.fsQuad.render(renderer);
      }
      // Restore renderer settings
      renderer.setClearColor(this.oldClearColor, this.oldClearAlpha);
      renderer.autoClear = oldAutoClear;
    },
    getSeperableBlurMaterial: function (kernelRadius) {
      return new ShaderMaterial({
        defines: {
          KERNEL_RADIUS: kernelRadius,
          SIGMA: kernelRadius,
        },
        uniforms: {
          colorTexture: { value: null },
          texSize: { value: new Vector2(0.5, 0.5) },
          direction: { value: new Vector2(0.5, 0.5) },
        },
        vertexShader:
          'varying vec2 vUv;\n\
        void main() {\n\
          vUv = uv;\n\
          gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\
        }',
        fragmentShader:
          '#include <common>\
        varying vec2 vUv;\n\
        uniform sampler2D colorTexture;\n\
        uniform vec2 texSize;\
        uniform vec2 direction;\
        \
        float gaussianPdf(in float x, in float sigma) {\
          return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;\
        }\
        void main() {\n\
          vec2 invSize = 1.0 / texSize;\
          float fSigma = float(SIGMA);\
          float weightSum = gaussianPdf(0.0, fSigma);\
          float alphaSum = 0.0;\
          vec3 diffuseSum = texture2D( colorTexture, vUv).rgb * weightSum;\
          for( int i = 1; i < KERNEL_RADIUS; i ++ ) {\
            float x = float(i);\
            float w = gaussianPdf(x, fSigma);\
            vec2 uvOffset = direction * invSize * x;\
            vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);\
            vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);\
            diffuseSum += (sample1.rgb + sample2.rgb) * w;\
            alphaSum += (sample1.a + sample2.a) * w;\
            weightSum += 2.0 * w;\
          }\
          gl_FragColor = vec4(diffuseSum/weightSum, alphaSum/weightSum);\n\
        }',
      });
    },
    getCompositeMaterial: function (nMips) {
      return new ShaderMaterial({
        defines: {
          NUM_MIPS: nMips,
        },
        uniforms: {
          blurTexture1: { value: null },
          blurTexture2: { value: null },
          blurTexture3: { value: null },
          blurTexture4: { value: null },
          blurTexture5: { value: null },
          dirtTexture: { value: null },
          bloomStrength: { value: 1.0 },
          bloomFactors: { value: null },
          bloomTintColors: { value: null },
          bloomRadius: { value: 0.0 },
        },
        vertexShader:
          'varying vec2 vUv;\n\
        void main() {\n\
          vUv = uv;\n\
          gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\
        }',
        fragmentShader:
          'varying vec2 vUv;\
        uniform sampler2D blurTexture1;\
        uniform sampler2D blurTexture2;\
        uniform sampler2D blurTexture3;\
        uniform sampler2D blurTexture4;\
        uniform sampler2D blurTexture5;\
        uniform sampler2D dirtTexture;\
        uniform float bloomStrength;\
        uniform float bloomRadius;\
        uniform float bloomFactors[NUM_MIPS];\
        uniform vec3 bloomTintColors[NUM_MIPS];\
        \
        float lerpBloomFactor(const in float factor) { \
          float mirrorFactor = 1.2 - factor;\
          return mix(factor, mirrorFactor, bloomRadius);\
        }\
        \
        void main() {\
          gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) + \
                           lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) + \
                           lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) + \
                           lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) + \
                           lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );\
        }',
      });
    },
  }
);
UnrealBloomPass.BlurDirectionX = new Vector2(1.0, 0.0);
UnrealBloomPass.BlurDirectionY = new Vector2(0.0, 1.0);

移动端画面模糊

在开发过程中发现,PC上显示正常的画面,到了移动端变模糊了一些,效果很一般。

这其实和设备的每个虚拟像素单位上的物理像素数量有关,由于移动端大部分都是高清屏,在一个虚拟像素点上存在多个物理像素。

设备像素比 = 设备像素 / 虚拟像素
DPR = DP / DIP

从上面的图可以看出,在同样大小的逻辑像素下,高清屏所具有的物理像素更多。普通屏幕下,1个虚拟像素对应1个物理像素,而在dpr = 2的高清屏幕下,1个逻辑像素由4个物理像素组成。这也是为什么高清屏更加细腻的原因。同时,为了保证显示效果,高清屏普遍应用了平滑处理技术,最终导致移动端上绘制出来的画面显得有些模糊。

解决方案:

// 将 renderer 的像素比设置到和 window 一致即可
renderer.setPixelRatio(window.devicePixelRatio);

动画过程处理

游戏中涉及到多个场景的转换,太阳、地球、月球和相机都有不同的位置、大小、朝向等变化,我们需要一个平滑的动画曲线去移动物体、切换场景,可以借助一些第三方库,如 tween.js,ola.js 等。

这样我们就可以只关注场景切换的起始状态和最终状态,无需关心中间过程。

数学知识

搬砖多年,在这个项目中找到了九年义务教育的必要性 :-)。捡起了很多多年未用的数学知识,包括:正弦函数转换、奇偶函数、曲线函数转换、平面/立体几何等。

three.js 的部分实现的时候其实没有用到太多数学知识,因为这些东西其实都被大佬们封装在了底层,在这一块需要的是一定的空间想象力(根据坐标想象场景布局)。

实际上用到数学知识的地方是在最终的蓄力、计分、角度计算那一块(在后面有总结),用到的知识其实都是高中时期很简单的内容,不过时隔多年还是略显生疏 -。-(脑子这种东西果然还是得多用用)

场景单位与屏幕像素单位的转换

有些效果在 three.js 中实现很复杂,可以通过 2D 蒙层等方式来满足,会遇到 three.js 中物体位置与屏幕位置的对应关系转换。以下是一些封装好的比较常用的 API:

  • 获取场景中的物体在屏幕上的实际位置

    import { Vector3 } from 'three';
    export function toScreenPosition (obj, camera) {
      const vector = new Vector3();
      const widthHalf = 0.5 * window.innerWidth;
      const heightHalf = 0.5 * window.innerHeight;
      obj.updateMatrixWorld();
      vector.setFromMatrixPosition(obj.matrixWorld);
      vector.project(camera);
      vector.x = vector.x * widthHalf + widthHalf;
      vector.y = -(vector.y * heightHalf) + heightHalf;
    
      // 这是物体中心的位置,物体的大小需额外考虑
      return {
        left: vector.x,
        top: vector.y,
      };
    }
    
  • 获取场景中的物体在屏幕上的实际尺寸

    /**
     * width: 在场景中物体的大小
     * dist: 场景中物体距离相机的距离
     */
    function getScreenWidth (camera, width, dist) {
      const vFov = camera.fov * Math.PI / 180;
      const height = 2 * Math.tan(vFov / 2) * dist;
      const proportion = width / height;
      // 一般而言渲染画面的高度是 window.innerHeight,如果不是自行更换
      return window.innerHeight * proportion;
    }
    

游戏规则经验总结

由于游戏规则要求可玩性较高,玩家长按月球时,蓄力条会开始蓄力,在蓄力条上会有固定的绿色区域表示高分区域,因此计分规则基于蓄力条高度来控制。流程如下:

  1. 根据蓄力时间计算蓄力条高度

  2. 根据蓄力条高度计算月球公转角度

  3. 根据公转角度获取最终分数(蓄力在绿色区域最中间时月球公转到虚线圆圈位置为满分)

给个游戏界面截图方便理解:

蓄力条速度控制

需求是蓄力条逐渐蓄满,然后回落,循环往复。最简单的方式是通过函数求值,先取 2 次函数的一段,然后做对称转换,得出两段函数,函数表达式为

x ∈ [0, MAX_POWER_TIME] 时,y=(x / MAX_POWER_TIME)^2
x ∈ [MAX_POWER_TIME, 2 * MAX_POWER_TIME] 时,y=((2 * MAX_POWER_TIME - x) / MAX_POWER_TIME)^2

图形大概是这样:

那循环怎么办?很简单,x 对 2 * MAX_POWER_TIME 取模即可。

利用函数求值的好处在于当产品或设计觉得蓄力体验不佳时,可以通过函数转换很容易的调整各个参数。同时,函数转换也可以和 three.js 中的 GUI 配合使用,可以让产品、设计自己调整蓄力规则。

相关代码:

// MAX_HEIGHT 为蓄力条的最大高度
// MAX_POWER_TIME 为蓄力条蓄力到最大高度所需时间
// 蓄力函数:x 属于 [0, MAX_POWER_TIME] 时函数为 y=(x/MAX_POWER_TIME)^2
// x 属于 [MAX_POWER_TIME, 2 * MAX_POWER_TIME] 时函数为 y=((2*MAX_POWER_TIME - x)/MAX_POWER_TIME)^2
// 两段关于 x=MAX_POWER_TIME 对称
export function getPowerHeightByTime (time) {
  // 对 2 * MAX_POWER_TIME 取模, 达到循环效果
  time = time % (2 * MAX_POWER_TIME);
  let percent;
  if (time <= MAX_POWER_TIME) {
    percent = Math.pow((time / MAX_POWER_TIME), 2);
  } else {
    percent = Math.pow(2 - (time / MAX_POWER_TIME), 2);
  }
  return parseInt(percent * MAX_HEIGHT);
}

根据蓄力高度获取月球公转角度

月球旋转角度和蓄力高度成正比,这样用户容易根据运行规则去调整蓄力高度。需要保证:

  1. 蓄力高度为 MAX_HEIGHT(蓄力最大值) 时,旋转角度为 MAX_POWER_CIRCLE 2 PI。
  2. 蓄力高度 height = MAX_SCORE_HEIGHT(绿色区域最中间的高度) 时,旋转角度为 MAX_POWER_CIRCLE 2 PI - PI。

根据以上两个点可以推算出直线函数:

 y = (x - MAX_HEIGHT) * PI / (MAX_HEIGHT - MAX_SCORE_HEIGHT) + 2 * MAX_POWER_CIRCLE * PI;

y为最终旋转角度,x为蓄力高度。

相关代码:

export function getDegByHeight (height) {
  // 旋转角度和 height 成正比
  const deg = (height - MAX_HEIGHT) * PI / (MAX_HEIGHT - MAX_SCORE_HEIGHT) + 2 * MAX_POWER_CIRCLE * PI;
  // 顺时针旋转月球
  return -deg;
}

根据月球公转角度计算最终得分

需要满足的条件有:

  1. 要求绿色区域的分数比较集中(高分区域分数集中一点)
  2. 边缘分数分差要大
  3. 月球在地球下方时分数为 0

很明显是一个简单的正态分布函数,这里利用一个变换的正弦函数模拟,根据最后一圈公转角度与满分位置 PI 的偏差计算最终分数,偏差越多分数越低。

函数公式:

y = 100 * sin(x + PI / 2)

相关代码:

export function getScoreByDeg (deg) {
  // 取模 2*PI 获得最后一圈的公转角度
  deg = Math.abs(deg % (2 * PI));
  if (deg > PI / 2 && deg < PI * 3 / 2) {
    const offset = Math.abs(deg - PI);
    const score = 100 * Math.sin(offset + PI / 2);
    return parseInt(score);
  } else {
    // 月球在地球下方时分数为0
    return 0;
  }
}

根据最终太阳和月球的位置绘制角

在展示成绩时有一个角度展开动画,需要绘制一个虚线锐角,需要计算的东西有:

  1. 太阳与月球连线的长度
  2. 角度大小(太阳与月球的连线有一个角展开动画)

计算出以上所需数据后,需要做一个太阳到地球的连线动画,连线完成后展开太阳月球连线的边到对应位置(展开的同时绘制角的圆弧线)

最终效果图:

将效果图简化成我们熟悉的平面几何图(随便画的有点丑,将就看 -。-)

已知 A, B, C 的中心位置,求锐角边 a 的长度很简单,直接通过勾股定理即可计算出,角度 b 的大小也可通过 atan 函数求出。

// 根据太阳月球的位置计算角度 b 的大小
export function getAngle (sun, moon) {
  const side1 = Math.abs(sun.left - moon.left);
  const side2 = moon.top - sun.top;
  const deg = Math.atan(side1 / side2) * 180 / Math.PI;
  return sun.left > moon.left ? deg : -deg;
}

角 b 两个边的延长和展开都很简单,通过 transition 和 transform 可以很轻松的实现,这里就不赘述了,说说角 b 的圆弧是怎么绘制出来的。

这里其实是通过 css 属性 clip-path 实现的,先以太阳中心位置为圆心,画一个虚线圆,然后根据角度 b 的大小算出裁剪比例(不了解 clip-path 的自行了解一下)。

大概画了一下:

虚线圆的半径已知,根据相似三角形规则很容易得出 a 的长度,因此可以获得 a 占的比例大小,最终获得 clip-path 的参数,剪裁出对应的圆弧。

相关代码:

// style,参数在 50 的左右浮动
clipPath: `polygon(50% 0%, ${50 + getArcPercent(sunPosition, moonPosition)}% 100%, 50% 100%)`
// javascript
export function getArcPercent (sun, moon) {
  // 近似三角形计算 a 的长度
  const proportion = CIRCLE_RADIUS / (moon.top - sun.top);
  const arcWidth = Math.abs(moon.left - sun.left) * proportion;
  const percent = parseInt(arcWidth / CIRCLE_RADIUS * 50); // 圆半径是正方形边长的一半,因此是 * 50
  return moon.left < sun.left ? -percent : percent;
}

最后

字节跳动长期招收前端、后端、客户端等各种岗位,实习校招社招均有大量坑位,base全国各地,有意者可邮件我内推~

0%