《穿越宇宙的邀请函》没你想得那么难

1,742 阅读7分钟
原文链接: zhuanlan.zhihu.com

前几天天猫双十一预热页面——《穿越宇宙的邀请函》,几乎是迄今为止移动端大型活动运营页面中最炫酷的一个,狠狠地刷了一波朋友圈。如果还没看过,可以在移动端打开这个链接欣赏一下。这里从技术角度对它进行一些比较详细的分析。

先上结论,这个页面在实现上并不复杂,不管是技术上还是设计上难度都不大。技术上虽然之前出现得不多,但是仍比你想得要简单得多。设计上,这创意对大多数人比较新奇,但也不是没有相似的先例(但是设计的工作量还是挺大的)。难得的是设计方案与技术方案结合得非常好,并且准确地把握了当前这个特殊的时间节点。

这里首先解释一下两个名词: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))
    }