前几天天猫双十一预热页面——《穿越宇宙的邀请函》,几乎是迄今为止移动端大型活动运营页面中最炫酷的一个,狠狠地刷了一波朋友圈。如果还没看过,可以在移动端打开这个链接欣赏一下。这里从技术角度对它进行一些比较详细的分析。
先上结论,这个页面在实现上并不复杂,不管是技术上还是设计上难度都不大。技术上虽然之前出现得不多,但是仍比你想得要简单得多。设计上,这创意对大多数人比较新奇,但也不是没有相似的先例(但是设计的工作量还是挺大的)。难得的是设计方案与技术方案结合得非常好,并且准确地把握了当前这个特殊的时间节点。
这里首先解释一下两个名词:WebGL和ThreeJS,也就是这个页面用到的关键技术。WebGL是浏览器提供的用于绘制高性能图形的一套接口,基本上可以理解为OpenGL的Web版本;ThreeJS是当前最流行的基于WebGL的开源3D图形库(github上的star接近3w,只比vue.js少一点点)。想要了解更多可以看我之前的一篇文章,Three.js快速入门。
想要在浏览器中使用WebGL,需要两个层次的支持:一个是硬件或者说系统底层的支持,另一个是浏览器的支持。硬件的支持一直都比较好,而浏览器的支持却不太乐观。iOS方面,iOS8就已经支持webGL,倒没什么问题;但Android原生浏览器直到5.0才开始部分支持WebGL,显然,这并不是可看造就的比例。
不过微信中的webview比较特殊。腾讯系的应用大多接入了x5内核,x5内核是基于webkit内核研发的,一开始就支持WebGL。今年发布的2.x版本更是大大提高了WebGL的稳定性。因此,微信中的页面对WebGL的支持是比较好的。
WebGL的支持率肯定是一直在上升的,但是具体数据就不清楚了;另外对大型运营活动而言,需要多高的支持率才能采用这样的技术方案,也是见仁见智。(不支持WebGL可以降级为canvas渲染,但是性能很差。)
所有代码被打包成三个文件:index.html,main.js,wdata.js。html文件不是我们关注的重点;wdata.js是个资源文件,主要是base64的图片,以及各个图片在三维场景中的位置,暂且略过;页面的主要逻辑都在main.js这个文件中,也是我们要关注的部分。
我们主要关注最后3D相关的部分。其实只有两个函数,一个是canvas渲染相关的,一个是webgl渲染相关的。看得出来对不支持webgl的机型是做了降级的,降级到canvas渲染。聊胜于无吧,canvas的性能还是很难支持这样的效果,算是给这些机型一个交代了。
ThreeJS的canvas渲染和webgl渲染接口是基本一致的,这里的两个函数的代码也只有少许不同,因此我们只关注WebGL渲染的部分。
如果你对场景、相机等基本概念还不了解,可以去读这篇ThreeJS快速入门。
这个函数大约500行,基本的思路是,按调好的位置,加载图片资源到场景中,然后根据用户输入移动相机和场景。
这里采用了一个主场景,主场景下挂了一个主Group。另外还有18个场景作为类似于缓存的存在,这18个场景代表了最终呈现效果的不同区域。当相机进入一个区域后,会把需要加载的场景中的Group挂到主Group下,离开时再删掉,这样渲染主场景就能看到想要的部分了。这样做的好处主要是性能上的,毕竟场景很大图片很多,如果不管能不能看到的都渲染一遍,性能消耗会增加很多。
前面提到,这个页面技术方案与设计方案结合得很好。
为什么这么说?因为在效果比较好的情况下,技术的实现难度,性能,兼容性都有比较好的结果。
实现难度在上面一节已经可以看得出来了,确实不难。
性能上,虽然最终呈现的是比较炫酷的3D效果,但实际使用的素材都是2D的。虽然用到了两百多个Sprite,但其实面数很少啊,何况还是部分渲染。整个页面做下来,性能比简单地展示一个3D模型还要好。
兼容性上,虽然很多机型是支持WebGL的,但是不代表它们真的能支持好啊。另外ThreeJS这个库虽然这么火,更新频率也很高,但是到现在也还有一堆坑等着填。而这里的方案,只是在3D场景中渲染了一堆带贴图的2D平面,从自己写的代码,到ThreeJS,到更底层,涉及到的内容都比较简单。这就大大减少了踩坑的机率。
从技术角度看这个页面大概就是这样了。最近为了满足业务需求,也去研究了一番ThreeJS,因此看到用了这个技术的页面就想去深入地看看,但是自身水平不高,写得也不好,有什么问题欢迎指正。
function(A, g, t) {
(function(g, e) {
var C = t(3)
, I = t(4)
, i = g.extend({}, g.Events, {
stage: null ,
camera: null ,
scene: null ,
renderer: null ,
effect: null ,
root: null ,
preload: null ,
stats: null ,
raycaster: null ,
mouse: null ,
fix: {
x: 0,
y: 0,
z: 0
},
aim: {
x: 0,
y: 0,
z: 0
},
data: null ,
init: function(A) {
I.ga(I.EVENT, "Ver", "Webgl");
var g = this;
this.stage = A,
this.camera = new THREE.PerspectiveCamera(60,window.innerWidth / window.innerHeight,1,1e6),
this.camera.position.z = 500,
this.scene = new THREE.Scene,
this.root = new THREE.Group,
this.root.position.y = -300,
this.root.position.z = 5e3,
this.scene.add(this.root),
this.raycaster = new THREE.Raycaster,
this.mouse = new THREE.Vector2;
var e = [{
url: t(11),
w: 960,
h: 960,
x: 0,
y: 0,
z: 0,
s: .4
}, {
url: t(12),
w: 227,
h: 960,
x: 0,
y: 0,
z: 100,
s: .8,
r: 30
}, {
url: t(18),
w: 16,
h: 256,
x: 0,
y: 0,
z: 0,
s: 4,
n: 20,
t: .5
}, {
url: t(19),
w: 431,
h: 532,
x: 0,
y: 0,
z: 0,
s: 4,
n: 6,
t: 4
}];
this.preload = this.createPreload(e),
this.preload.position.set(0, 300, -5e3),
this.root.add(this.preload),
this.preloadOn(),
this.renderer = new THREE.WebGLRenderer,
this.renderer.setClearColor(1245234),
this.renderer.setPixelRatio(window.devicePixelRatio),
this.renderer.setSize(window.innerWidth, window.innerHeight),
this.stage.prepend(this.renderer.domElement),
this.effect = new THREE.StereoEffect(this.renderer),
this.effect.eyeSeparation = 100,
this.effect.setSize(window.innerWidth, window.innerHeight),
t.e(0, function() {
g.data = t(14),
g.complete()
})
},
complete: function() {
var A = this.createSprite(this.data.bg);
this.addScene({
scene: A,
max: 2400,
min: -29650
});
var g = this.createScene(this.data.data1);
g.position.set(0, 0, 0),
this.addScene({
scene: g,
max: 2400,
min: -12e3
});
var t = this.createScene(this.data.data2);
t.position.set(0, 0, 1500),
this.addScene({
scene: t,
max: 2400,
min: -12e3
});
var e = this.createScene(this.data.data3);
e.position.set(0, 0, 4500),
this.addScene({
scene: e,
max: 2400,
min: -12e3
});
var C = this.createSprite(this.data.bg2);
this.addScene({
scene: C,
max: -12e3,
min: -29650
});
var I = this.createScene(this.data.data4);
I.position.set(0, 0, 7200),
this.addScene({
scene: I,
max: -4900,
min: -29650
});
var i = this.createScene(this.data.data5);
i.position.set(0, 0, 8500),
this.addScene({
scene: i,
max: -7e3,
min: -29650
});
var n = this.createScene(this.data.data6);
n.position.set(0, 0, 11e3),
this.addScene({
scene: n,
max: -11e3,
min: -29650
});
var o = this.createScene(this.data.data7);
o.position.set(0, 0, 18e3),
this.addScene({
scene: o,
max: -15500,
min: -29650
});
var a = this.createSprite(this.data.bg3);
this.addScene({
scene: a,
max: -29650,
min: -43600
});
var r = this.createSprite(this.data.vr);
this.addScene({
scene: r,
max: -29300,
min: -43600
});
var s = this.createScene(this.data.data8);
s.position.set(0, 0, 3e4),
this.addScene({
scene: s,
max: -29650,
min: -43600
});
var c = this.createScene(this.data.data9);
c.position.set(0, 0, 31e3),
this.addScene({
scene: c,
max: -36e3,
min: -6e4
});
var h = this.createScene(this.data.data10);
h.position.set(0, 0, 46e3);
var l = this.createParticles(this.data.red, 80, 50, 1e3, .2, {
x: 1,
y: 1,
z: 5
});
l.position.set(0, 300, 5e3),
h.add(l),
this.addScene({
scene: h,
max: -42e3,
min: -6e4
});
var u = this.createScene(this.data.data11);
u.position.set(0, 0, 56e3),
this.addScene({
scene: u,
max: -42e3,
min: -5e5
});
var d = this.createScene(this.data.data12);
d.position.set(0, 0, 61e3),
this.addScene({
scene: d,
max: -29650,
min: -5e5
});
var p = this.createScene(this.data.data13);
p.position.set(0, 0, 73e3),
this.addScene({
scene: p,
max: -71e3,
min: -5e5
}),
this.dots = this.createScene(this.data.data14),
this.dots.position.set(0, 0, 0),
this.addScene({
scene: this.dots,
max: 0,
min: -5e5
})
},
tap: function(A) {
this.mouse.x = A.x / window.innerWidth * 2 - 1,
this.mouse.y = 2 * -(A.y / window.innerHeight) + 1,
this.raycaster.setFromCamera(this.mouse, this.camera);
var g = this.raycaster.intersectObjects(this.dots.children);
g.length > 0 && (g[0].object.name < 11 ? this.trigger("dot", g[0].object.name) : 11 == g[0].object.name && this.trigger("share"))
},
preloadData: {
max: 0,
cur: 0
},
checkPreload: function() {
this.trigger("preloadProgress", Math.floor(this.preloadData.cur / this.preloadData.max * 100)),
this.preloadData.cur >= this.preloadData.max && this.preloadOff()
},
preloadOn: function() {
function A(A) {
if (A.n0 > 10) {
var t = g(700, 900);
A.position.set(t.x, t.y, 0),
A.material.rotation = t.r + Math.PI / 2;
var e = .4 * Math.random() + .2;
A.scale.set(A.w0 * e, A.h0 * e, 1)
} else {
var t = g(200, 500);
A.position.set(t.x, t.y, 0),
A.material.rotation = Math.random() * Math.PI;
var e = 1 * Math.random() + .5;
A.scale.set(A.w0 * e, A.h0 * e, 1)
}
}
function g(A, g) {
var t = C.random(A, g)
, e = Math.random() * Math.PI * 2
, I = Math.sin(e) * t
, i = Math.cos(e) * t;
return {
x: i,
y: I,
r: e
}
}
I.ga(I.PAGE, "loading", "loading_scene"),
C.isPreloaded = !1,
$.each(this.preload.children, function(g, t) {
0 == g && e.fromTo(t.scale, .01, {
x: 1 * t.w0,
y: 1 * t.h0
}, {
x: 1.1 * t.w0,
y: 1.1 * t.h0,
yoyo: !0,
repeat: -1,
onUpdate: function() {
t.material.rotation = .1 * Math.random() - .2
}
}),
g 1 || (A(t),
e.fromTo(t.position, t.t0, {
z: 500
}, {
z: -2e4,
delay: t.i0 * (t.t0 / t.n0),
repeat: -1,
onRepeat: function() {
A(t)
}
}))
}),
this.trigger("preloadOn")
},
preloadOff: function() {
var A = this;
C.isPreloaded = !0,
e.to(this.root.position, 4, {
z: -11500,
ease: e.Quad.InOut,
onEnd: function() {
$.each(A.preload.children, function(A, g) {
e.kill(g)
}),
A.root.remove(A.preload),
e.to(this.target, 3, {
z: 400,
ease: e.Quad.In,
onEnd: function() {
C.isReady = !0,
A.trigger("ready")
}
})
}
}),
this.trigger("preloadOff")
},
createPreload: function(A) {
for (var g = new THREE.Group, t = 0, e = A.length; t < e; t++)
for (var C = A[t].n || 1, I = 0; I < C; I++) {
var i = (new THREE.TextureLoader).load(A[t].url)
, n = new THREE.SpriteMaterial({
map: i
})
, o = (A[t].turn ? -1 : 1) * A[t].w * (A[t].s || 1)
, a = A[t].h * (A[t].s || 1)
, r = new THREE.Sprite(n);
r.position.set(A[t].x, A[t].y, A[t].z),
r.scale.set(o, a, 1),
r.w0 = o,
r.h0 = a,
r.t0 = A[t].t,
r.n0 = A[t].n,
r.i0 = I,
A[t].r && (r.material.rotation = A[t].r / 180 * Math.PI),
A[t].name && (r.name = A[t].name),
g.add(r)
}
return g
},
createScene: function(A) {
var g = this
, t = new THREE.Group;
t.childs = [];
for (var e = 0, C = A.length; e < C; e++) {
this.preloadData.max++;
var I = (new THREE.TextureLoader).load(A[e].url, function() {
g.preloadData.cur++,
g.checkPreload()
})
, i = new THREE.SpriteMaterial({
map: I
})
, n = new THREE.Sprite(i);
if (n.position.set(A[e].x, A[e].y, A[e].z),
n.scale.set((A[e].turn ? -1 : 1) * A[e].w * (A[e].s || 1), A[e].h * (A[e].s || 1), 1),
A[e].r && (n.material.rotation = A[e].r / 180 * Math.PI),
A[e].name && (n.name = A[e].name),
t.add(n),
n.isIn = !0,
t.childs.push(n),
!A[e].single) {
var o = new THREE.Sprite(i);
o.position.set(-A[e].x, A[e].y, A[e].z),
o.scale.set(-A[e].w * (A[e].s || 1), A[e].h * (A[e].s || 1), 1),
t.add(o),
o.isIn = !0,
t.childs.push(o)
}
A[e].walk && (A[e].walk.target = n,
this.addWalk(A[e].walk))
}
return t
},
createSprite: function(A) {
var g = new THREE.SpriteMaterial({
map: (new THREE.TextureLoader).load(A.url)
})
, t = new THREE.Sprite(g);
return t.position.set(A.x, A.y, A.z),
t.scale.set(A.w * (A.s || 1), A.h * (A.s || 1), 1),
t
},
createParticles: function(A, g, t, e, C, I) {
for (var i = new THREE.Group, n = [], o = 0, a = A.length; o < a; o++)
n[o] = new THREE.SpriteMaterial({
map: (new THREE.TextureLoader).load(A[o].url)
});
for (var r = 0; r < g; r++) {
var s = r % a
, c = new THREE.Sprite(n[s])
, h = Math.random() * (e - t) + t
, l = Math.random() * Math.PI * 2
, u = Math.random() * Math.PI * 2
, d = Math.sin(u) * h
, p = Math.cos(u) * h
, f = Math.cos(l) * p
, E = Math.sin(l) * p;
c.position.set(f * I.x, d * I.y, E * I.z);
var w = 2 * Math.random() + 1;
c.scale.set(A[s].w * w * C, A[s].h * w * C, 1),
i.add(c)
}
return i
},
resize: function() {
C.checkLandscape() ? (this.camera.aspect = window.innerWidth / window.innerHeight,
this.camera.updateProjectionMatrix(),
this.effect.setSize(window.innerWidth, window.innerHeight),
this.renderer.setSize(window.innerWidth, window.innerHeight)) : C.isVR ? (this.camera.aspect = window.innerHeight / window.innerWidth,
this.camera.updateProjectionMatrix(),
this.effect.setSize(window.innerHeight, window.innerWidth),
this.renderer.setSize(window.innerHeight, window.innerWidth)) : (this.camera.aspect = window.innerWidth / window.innerHeight,
this.camera.updateProjectionMatrix(),
this.effect.setSize(window.innerWidth, window.innerHeight),
this.renderer.setSize(window.innerWidth, window.innerHeight))
},
render: function() {
if (C.isActive) {
var A;
A = this.root.position.z < -44e3 ? this.aim.z * Math.max(1, (-this.root.position.z - 4e4) / 1e4) : this.aim.z,
C.isReady && (this.root.position.z > 200 && A > 0 && (A *= 1 - (this.root.position.z - 200) / 200),
this.root.position.z < -168e3 && A < 0 && (A *= 1 - (-this.root.position.z - 168e3) / 8e3),
this.root.position.z += A),
this.root.position.z < -20200 && (C.isFirst || (C.isFirst = !0,
e.to(this.dots.childs[11].material, .5, {
opacity: 0,
delay: 5
}))),
this.updateWalk(this.root.position.z),
this.updateScene(this.root.position.z),
this.camera.rotation.x += .3 * (this.fix.y - this.camera.rotation.x),
this.camera.rotation.y += .3 * (this.fix.x - this.camera.rotation.y),
C.checkLandscape() || C.isVR ? this.effect.render(this.scene, this.camera) : this.renderer.render(this.scene, this.camera)
}
},
walks: [],
addWalk: function(A) {
this.walks.push(A)
},
updateWalk: function(A) {
$.each(this.walks, function(g, t) {
var C = Math.max(t.from.a, t.to.a)
, I = Math.min(t.from.a, t.to.a);
if (A > I && A < C)
for (var g in t.from) {
switch (g) {
case "x":
case "y":
case "z":
case "w":
case "h":
case "r":
var i = (A - t.from.a) / (t.to.a - t.from.a)
, n = t.to.ease || e.Linear.None
, o = n(i)
, a = t.from[g] + (t.to[g] - t.from[g]) * o
}
switch (g) {
case "x":
case "y":
case "z":
t.target.position[g] = a;
break;
case "w":
t.target.scale.x = a;
case "h":
t.target.scale.y = a;
break;
case "r":
t.target.material.rotation = a / 180 * Math.PI
}
}
})
},
scenes: [],
addScene: function(A) {
this.scenes.push(A),
this.root.add(A.scene),
A.isIn = !0
},
updateScene: function(A) {
var g = this;
$.each(this.scenes, function(t, e) {
var C = Math.max(e.max, e.min)
, I = Math.min(e.max, e.min);
A > I && A < C ? e.isIn || (e.isIn = !0,
g.root.add(e.scene)) : e.isIn && (e.isIn = !1,
g.root.remove(e.scene))
})
},
animate: function() {
i.render(),
requestAnimationFrame(i.animate)
},
toDot: function(A) {
this.aim.z = 0;
var g = this.dots.childs[A - 1]
, t = -g.position.z - 300
, C = Math.min(3, Math.abs(this.root.position.z - t) / 1e4);
e.kill(this.root),
e.to(this.root.position, C, {
z: t,
ease: e.Quad.InOut
})
},
checkVR: function() {
this.resize()
}
});
A.exports = i
}
).call(g, t(1), t(2))
}