内容简介:2020年6月21日,中国大部分地区可观测到日环食现象,头条热点联合新华社开展了一个日环食H5活动,以游戏的形式展开,附带直播、资讯等内容分发。我负责本次运营活动的 H5 游戏部分开发。体验入口(仅适配移动端):实现基于 threejs, 以下是游戏开始界面。
2020年6月21日,中国大部分地区可观测到日环食现象,头条热点联合新华社开展了一个日环食H5活动,以游戏的形式展开,附带直播、资讯等内容分发。我负责本次运营活动的 H5 游戏部分开发。
体验入口(仅适配移动端):
-
今日头条端内搜索 “日环食大挑战”
-
链接直接打开: 日环食大挑战
实现基于 threejs, 以下是游戏开始界面。
three.js躺坑经验
在这次活动的开发中,由于是第一次接触 three.js,过程中踩了不少坑,总结了一些经验供他人参考(坑这种东西,有一个人躺过就行了)
对于从未接触过 three.js 的同学,建议先照着 起步文档 先写一个小 demo,这有助于你对 three.js 的一些核心概念:场景、相机、物体、材质有一个初步的认识。在对核心概念有一定了解之后,就可以着手尝试需求中的一些效果,多写写 demo,提前把坑躺了,降低实际开发时的风险。
以下是一些我在开发过程中躺过的坑(有些没掉进去,但是总感觉会有年轻的小朋友躺进去,也列出来了):
场景初始化
新手接触 three.js 很容易遇到的问题是,我已经把场景、模型、相机什么的都写好了,为什么打开是一团黑?问题可能存在以下几点:
- 光源问题
有几种光源问题会导致物体看不到的情况:
-
three.js 中物体分为很多材质,同时可以给物体贴图,当场景中没有光源投射到物体上时会看不到贴图。实际上物体是在的,但是因为没有光源,所以物体是黑的。
-
当场景中只有指向类光源(如平行光、聚光灯等),物体不在光源的覆盖范围时,物体同样是黑的。
建议:初始化场景时第一步先添加一个环境光,不需要的话后续再去掉即可
- 相机朝向位置或相机位置不对
相机(有不同类型,自行查看官网文档)初始化完成之后,默认朝向坐标和相机位置都是 (0, 0, 0),即原点位置(我盯我自己)。
一般而言初始化时都会设置一个相机位置,存在以下情况时会看不见物体:
-
物体不在相机的朝向位置
-
相机在物体内部
建议:
- 初始化时引入 control 插件,可以自行缩放、旋转相机角度,方便调试
- 物体设置不宜过大或过小(相对于物体和相机的距离而言)
反复调整问题
这个是前端老大难的问题,实际实现总是和设计、动效的预期偏离,需要不断的调整,简单的 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; }
游戏规则经验总结
由于游戏规则要求可玩性较高,玩家长按月球时,蓄力条会开始蓄力,在蓄力条上会有固定的绿色区域表示高分区域,因此计分规则基于蓄力条高度来控制。流程如下:
-
根据蓄力时间计算蓄力条高度
-
根据蓄力条高度计算月球公转角度
-
根据公转角度获取最终分数(蓄力在绿色区域最中间时月球公转到虚线圆圈位置为满分)
给个游戏界面截图方便理解:
蓄力条速度控制
需求是蓄力条逐渐蓄满,然后回落,循环往复。最简单的方式是通过函数求值,先取 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); }
根据蓄力高度获取月球公转角度
月球旋转角度和蓄力高度成正比,这样用户容易根据运行规则去调整蓄力高度。需要保证:
- 蓄力高度为 MAX_HEIGHT(蓄力最大值) 时,旋转角度为 MAX_POWER_CIRCLE 2 PI。
- 蓄力高度 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; }
根据月球公转角度计算最终得分
需要满足的条件有:
- 要求绿色区域的分数比较集中(高分区域分数集中一点)
- 边缘分数分差要大
- 月球在地球下方时分数为 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; } }
根据最终太阳和月球的位置绘制角
在展示成绩时有一个角度展开动画,需要绘制一个虚线锐角,需要计算的东西有:
- 太阳与月球连线的长度
- 角度大小(太阳与月球的连线有一个角展开动画)
计算出以上所需数据后,需要做一个太阳到地球的连线动画,连线完成后展开太阳月球连线的边到对应位置(展开的同时绘制角的圆弧线)
最终效果图:
将效果图简化成我们熟悉的平面几何图(随便画的有点丑,将就看 -。-)
已知 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全国各地,有意者可邮件我内推~
以上所述就是小编给大家介绍的《threejs游戏开发经验总结》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。