32

threejs游戏开发经验总结

 3 years ago
source link: https://quincychen.cn/threejs-experience/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

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

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

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

vQBRbq6.png!web

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);
  });

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

BBruiu7.png!web

物体模型构建

日环食活动中主要的物体模型有三个,太阳、地球和月球。以下是太阳的生成代码,相关的 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

zY7jAfz.png!web

从上面的图可以看出,在同样大小的逻辑像素下,高清屏所具有的物理像素更多。普通屏幕下,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. 根据公转角度获取最终分数(蓄力在绿色区域最中间时月球公转到虚线圆圈位置为满分)

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

UFFVnmv.png!web

蓄力条速度控制

需求是蓄力条逐渐蓄满,然后回落,循环往复。最简单的方式是通过函数求值,先取 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

图形大概是这样:

Y3e2iqm.png!web

那循环怎么办?很简单,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. 角度大小(太阳与月球的连线有一个角展开动画)

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

最终效果图:

ZnuuemA.png!web

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

FryyaqV.png!web

已知 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 的自行了解一下)。

大概画了一下:

QvA3YbM.png!web

虚线圆的半径已知,根据相似三角形规则很容易得出 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全国各地,有意者可邮件我内推~


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK