内容简介: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游戏开发经验总结》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。