Mapbox结合Deck.gl的高级着色器应用

Build Status Downloads Downloads

前言

这篇文件不是简单告知大家本文的效果怎么实现,而是给出一个能够实现复杂效果的思路与手段,实现本文描述的效果不算什么复杂的事情,但是在这个基础之上拓展进而形成自己的效果甚至更加复杂的效果才是这个文章的目的,所以实际掌握本文所描述的方法才是重点效果反倒不是那么重要。

一般来讲我很少将这些东西写出来原因是很多组件的官方文档与案例说的非常清楚了,基本上不需要怎么的学习便可以从文档之中获取对应的信息,但是deck.gl的文档可以说是我这么多年来见过最烂的文档(没有之一),所以非常有必要拿出来说一下。

前置条件

首先想要真正的理解下面的东西必须要有一定的基础才行,大致的前置条件如下所示:

  • 理解WebGL的绘制原理同时掌握GLSL语言下的Shader编程技术(参考资料)。
  • 理解Mapbox自定义图层的实现方法(参考资源)。
  • 具备算法编写能力,具备较好的数学基础,主要是一些几何学与线性代数,不然的话连抄算法代码都不知道怎么抄(这个全世界都是参考资料)。

实现效果

我们最终要实现的效果由三个部分组成,第一是地图也就是Mapbox的底图,它提供了完整的GIS功能与地图功能;第二是地图上面的三维建筑,这些建筑在样式上面有一个渐变效果;第三是一个垂直的包围盒,侧面有动态的烟雾效果。这些图层全部叠加在一起同时由Mapbox实现统一的控制最终的效果如下图所示:

实现流程与思路

1.拓展Mapbox的原理

首先我们最终的目的无非就是在地图上面添加了两个不同的图层而已,但是Mapbox的图层没有提供对原生图层的自定义方案而是提供了完全自定义图层的入口,但是这个入口本质上是将Mapbox的gl绘制上下文给出来然后由我们开发者添加新的着色器程序而已。但是这也就意味着想要实现自定义图层我们便需要使用原生WebGL开发,这对于我们这种用惯了引擎的人来讲简直要命所以它更多的作用便是用于其他WebGL引擎的整合,比如说整合ThreeJS到Mapbox或者整合DeckGL到Mapbox。好在Mapbox的文档写的非常不错并且封装也非常好所以整合行为本身不算复杂比如整合ThreeJS可以说非常轻松(整合原理:参考资料),但是由于DeckGL的文档垃圾封装也很神经想要整合DeckGL便有点伤了。

2.为什么使用DeckGL

使用DeckGL这个排除法出来的结果,首先我们需要一个东西能够提供对Mapbox实现拓展,我们对ThreeJS最为熟悉所以早就已经做好了他们的整合,但是ThreeJS虽然与Mapbox使用同一个上下文并且具备底层修改与二次编辑的能力,但是其渲染流程与渲染器控制基本上是ThreeJS自己实现的,这样一来导致问题便是性能问题,经过实际测试与生产环境验证在面对大规模建筑渲染时由于Mapbox的切片无法应用同时渲染管线分离导致性能非常低下,实测在超过三万建筑的情况之下集成显卡基本上只有10FPS的帧率所以无法接受。其次ThreeJS缺失了很多GIS相关的特性如世界空间坐标与墨卡托坐标就是两个完全不同的坐标系导致两个渲染层之间的帧同步性能开销巨大同时大量的坐标转换关系只能在CPU实现导致性能进一步降低。最后由于墨卡托坐标系下的坐标精度非常高导致ThreeJS在GPU之中的单精度浮点运算会丢失精度导致屏幕抖动问题(参考资料)所以即便是排除了大规模应用ThreeJS的方案。

其他WebGL三维引擎其实面对相同的问题如Babylon、PlayCanvas、ThingJS等,所以最终需要一个本身就是为GIS设计的三维引擎同时自身具备一定的Mapbox兼容性即便是只有两个东西:DeckGL和L7。其中L7框架是国内的开源项目,这个东西主要是做了高德地图的集成同时它非常顶层基本上是开箱即用的小白框架想要自定义就需要动源码,但是我们想要实现的效果它不能支持就只有DeckGL能够满足要求了,虽然它文档烂的一逼,封装也垃圾但是人家就是NB,要性能有性能,要精度有精度,要拓展有拓展。

3.集成DeckGL组件到Mapbox

首先看看这个东西的文档吧,没有相当的基础基本上看不懂它在说什么:DeckGL文档

想要集成DeckGL到Mapbox有四种方案第一是使用官方提供的DeckGL这个类,构造的时候可以提供Mapbox官方的Token以实现集成,但是这就意味这不能使用我们自己的Tiles数据排除;第二是使用React框架下的ReactMap这个东西本身也是Mapbox实现的但是我们的主要框架是Vue或者原生搞React代价过高排除;第三是通过官方提供MapboxLayer类,通过构造Deck全局对象并且实现各个图层在作为参数去构造Layer但是我们的图层管理是由Mapbox发起的使用Deck管理上过于复杂排除;第四是通过MapboxLayer类集成并且构造添加DeckGL的层Type实现独立图层的构造这个图层同时也继承于Mapbox的Layer所以可以由Mapbox统一管理也就是我们最终使用的方案。

最终我们的集成代码也比较简单如下所示:

// @ts-ignore
import { PolygonLayer } from "@deck.gl/layers";
// @ts-ignore
import { MapboxLayer } from "@deck.gl/mapbox-layer";
import uuid from "uuid";

export class IronRoadLasyer extends MapboxLayer {
    constructor(data: any) {
        super({
            id: uuid.v1(),
            type: PolygonLayer,
            data: data,
            extruded: true,
            pickable: false,
            stroked: true,
            filled: true,
            wireframe: false,
            lineWidthMinPixels: 1,
            // @ts-ignore
            getPolygon: d => d.geometry.coordinates[0][0],
            // @ts-ignore
            getElevation: d => d.properties.height * 50 * height,
            // @ts-ignore
            getFillColor: [255,0,0,255],
            // @ts-ignore
            getLineColor: [255,0,0,255],
        });
    }
}

这里我们创建了一个类,它继承与DeckGL的MapboxLayer同时是Mapbox的Layer类的派生,所以构造之后可以使用map.add()直接添加到Mapbox就可以了。同时这里所使用PolygonLayer是DeckGL的一个基础图层类,只需要指定图层类型之后在构造之中实现这个图层类的参数传入就可以了,这么说到这里还算是很简单。

4.自定义着色器

这里开始有一点麻烦了,首先着色器编写不同于Mapbox,DeckGL不会让你是用原始的WebGL去编写着色器控制着色逻辑,同时也不会要求你对整个着色器进行重写而是给出了一个拓展着色器的方案,这里其实是靠另一个框架实现的那就是lumaGL,这个东西本质上就是一个面向底层的三维引擎,它能够实现对单一着色单元的拓展,可以注入着色器代码到已经存在的着色器之中从而实现对已有着色器的自定义。

这里便是事情开始棘手的地方了,由于是对已有着色器的修改可能官方没有觉得很多人需要这个东西所以这里的文档开始变的跟屎一样,从官方文档上主要基础的注入手段之外基本上什么都看不出来了。我的思路是从源码上进行分析。

首先我们是对已有的着色器进行拓展那么必须要知道他着色器里面有些什么变量可以使用,在源码之中可以定位一个图层类,图层类之中如果没有glsl代码那就说明这个层是集成与其他层实现的我们对应找到相关的层就可以了。当定位到一个具体的层之后我们便可以看别人的shader代码是怎么一个逻辑,其中使用到的attribute与uniform可以在js代码之中找到从而串起来别人所有的量是怎么来的有什么作用。这里只有具体图层具体分析了,但是官方也提供了基础的可用量如Geometry结构体在顶点着色器之中可以使用世界坐标、局部坐标、法线坐标、UV坐标等,在片元着色器只有UV坐标可以使用。但是上面我们使用的PolygonLayer任意UV都是0那还自定义个锤子,不过通过对源码分析也找到了一个attribute值可以近似的看做是UV坐标那就是vertexPositions它是一个vec2,这个量在顶点着色器通过声明varying并且传入片元之中使用,本质上他就是UV坐标,侧面四个点的xy就是0和1。

5.拓展着色器

自定义着色器的思路有了之后便是对着色器的拓展行为,通过对lumaGL的分析与学习,它其实提供了一个东西叫做ShaderMoudle,这个东西提供了一堆的关键字用于注入shader,具体如下:

  • vs:#decl:向顶点着色器声明之中注入代码。
  • vs:#main-start:向顶点着色器主函数开始的地方注入代码。
  • vs:#main-end:向顶点着色器主函数结束的地方注入代码。
  • vs:DeckGL_FILTER_SIZE:向顶点着色器在投影计算前对注入整体缩放代码。
  • vs:DeckGL_FILTER_GL_POSITION:向顶点着色器最终坐标计算上注入重写代码。
  • vs:DeckGL_FILTER_COLOR:向顶点着色器注入顶点颜色重写代码。
  • fs:#decl:向片元着色器注入声明代码。
  • fs:#main-start:向片元着色器主函数开始的地方注入代码。
  • fs:#main-end:向片元着色器主函数结束的地方注入代码。
  • fs:DeckGL_FILTER_COLOR:向片元着色器注入最终的颜色重写代码。

但是实际的着色器拓展行为还是要依靠DeclGL来实现本质上就是有DeckGL在调度下层的绘制库,具体方案是继承它的LayerExtensions类并且重写getShader函数实现,代码如下:

//图层拓展器,用于对Shader进行重写与拓展
class BuildingFilter extends LayerExtension {
    getShaders() {
        return {
            inject: {
                //注入顶点着色器声明
                'vs:#decl': `
                    varying vec2 vPosition;
                `,
                //注入顶点着色器,对varying变量赋值
                'vs:#main-end': `
                    vPosition = vertexPositions;
                `,
                //注入片元着色器声明
                'fs:#decl': `
                    varying vec2 vPosition;
                `,
                //重写颜色绘制函数
                'fs:DECKGL_FILTER_COLOR': `
                    color = vec4(color.xyz, color.w * pow(vPosition.y,2.0));
                `
            }
        };
    }
}

逻辑上应该很容易看懂,我们在顶点之中声明了一个新的varying,然后在顶点主函数结束的时候将vertexPositions也就是上面说的UV值给这个varying,然后在片元之中获取插值坐标从而形成通过UV坐标控制最终透明度的效果,这样一来也就实现对建筑从上至下的渐变效果。

当然这个拓展器依旧需要应用给我们的图层类才能生效其实就是在图层类的构造参数之中加入一个extensions: [new BuildingFilter()]即可,代码如下:

// @ts-ignore
import { PolygonLayer } from "@deck.gl/layers";
// @ts-ignore
import { MapboxLayer } from "@deck.gl/mapbox-layer";
import uuid from "uuid";

export class IronRoadLasyer extends MapboxLayer {
    constructor(data: any) {
        super({
            id: uuid.v1(),
            type: PolygonLayer,
            data: data,
            extruded: true,
            pickable: false,
            stroked: true,
            filled: true,
            wireframe: false,
            lineWidthMinPixels: 1,
            // @ts-ignore
            getPolygon: d => d.geometry.coordinates[0][0],
            // @ts-ignore
            getElevation: d => d.properties.height * 50 * height,
            // @ts-ignore
            getFillColor: [255,0,0,255],
            // @ts-ignore
            getLineColor: [255,0,0,255],
            extensions: [new BuildingFilter()]
        });
    }
}

这样一来这个注入之后的着色器便会在这个图层上生效。

6.灯光效果

这个东西是一个无奈的行为,原因是DeckGL其实对Shader进行了封装上层,我们注入的代码其实不是直接修改最终的gl_FragColor而是修改一个中间变量color,这个变量的值将会通过它的光照函数计算进而给到gl_FragColor救过就是修改的颜色看着与设计的颜色不同,这里就非常难受了,即便是你不给场景任何灯光最终场景都会有一个默认的平行光参与计算并且我没有找到什么办法干掉这个光或者干掉这个光照函数的逻辑。哦办法到是由那就是从新从零开始写一个层,要了老命。

那么解决的办法只有加入更多的光照效果使得光照计算的结果更接近于本身的颜色,参考官方提供的文档创建一个光照非常简单:

const ambientLight = new AmbientLight({
    color: [255, 255, 255],
    intensity: 3
});

const light = new LightingEffect({ ambientLight });

我们有了一个全局的环境光,现在只需要把这个光给到全局的deck对象就可以了,但是不知道各位发现一个问题没有我们整个流程根本就没有看到也没有使用到全局的deck对象。。。。。。。

所以又开始看源码最好发现MapboxLayer这个类其实就是实现了Mapbox的onAdd()render()函数,这里面在onAdd里面通过一个函数this.deck = getDeckInstance({ map, gl, deck: this.props.deck });轻描淡写的获取到了deck全局对象,但是这是在源码里面,同时onAdd是在图层添加到Mapbox之后调用的,等于说在此之前根本就没有全局的deck对象,所以没有其他办法了只能改源码了,这里我的修改逻辑是首先deck是全局单例所以只需要在构造deck对象的时候添加一个等进去那么就会对所有的deck层生效,并且不会有其他问题,于是在deck-utils.js文件之中定位到getDeckInstance函数之后便是将这两个js文件复制到我们自己的项目之中并且加入我们自己的代码。找到new Deck()函数之后就简单了直接加入我们的代码就可以了:

//在deck-utils.js的差不多34行。
if (deck) {
    deck.setProps(deckProps);
    deck.props.userData.isExternal = true;
} else {
    // Using external gl context - do not set css size
    Object.assign(deckProps, {
        gl,
        width: false,
        height: false,
        viewState: getViewState(map)
    });
    deck = new Deck(deckProps);
    //这里就是创建环境光,然后添加到deck全局对象的效果器之中
    const ambientLight = new AmbientLight({
        color: [255, 255, 255],
        intensity: 3
    });
    const light = new LightingEffect({ ambientLight });
    deck.setProps({{ effects: light }})

这样一来加入环境光之后整体画面的颜色便非常接近我们设计给出的颜色了,当然也可以添加更多的光效果进去,经过我的测试应用光照效果不会特别影响性能,我估计它还是使用的延迟的着色器但是我源码也没有跟那么深只是猜测。

7.uniform变量传入

这个东西,官方的文档也说的非常模糊,但是实际的原理大家都知道,丢一个变量给缓冲去就可以了,这里传入的方式依旧是我们的LayerExtension,通过对updateState()getSubLayerProps()函数的重写最终应用shadermodel的setUniforms()函数便可以定义uniform变量代码如下:

// @ts-ignore
    updateState(params) {
        // @ts-ignore
        const { u_time = 1.0 } = params.props;
        // @ts-ignore
        for (const model of this.getModels()) {
            model.setUniforms({ u_time });
        }
    }
    // @ts-ignore
    getSubLayerProps() {
        // @ts-ignore
        const { u_time = 1.0 } = this.props;
        return {
            u_time
        };
    }

但是这里仅仅是告知ShaderMoudle去图层之中获取u_time这个变量作为一个uniform,变量值本身已经需要在图层之中去拿,直接在图层类构造加入一个u_time参数就可以,后续可以通过图层的setProps()函数实现对uniform变量值的更新。

关于attribute值的传入也比较简单通过DeckGL的AttributeManager类可以实现,只是如果需要传入attribute的话势必会对原始的attribute值获取或者计算,这样一来又会将整个层的代码抄出来重新,意义不大,目前来讲这些已有层加入自定义着色器已经住够使用,一定需要的传入attribute的时候那就重新完全自定义一个新的图层,这样的话直接抄一个出来改就可以了没有太多需要说的。

8.复杂着色器效果编写

首先我的数学水平非常一般,上面图片之中烟雾效果的本质上就是通过UV坐标加一个自增的世界值可以计算出来但是我只是知道能够计算上面的效果但是要我从零开始写的话那估计要憋非常久的时间才能写出来,核心原因是不知道什么噪波函数可以实现类似的效果只能去找数学公式、函数、再实现非常的费时间。怎么办呢?那就是抄!中国的程序员无论是什么算法工程师、人工智能、大数据本质上都是抄,抄的越多水平越高,如果别人写好了的话为毛我要重新写一遍,企业不予许、项目经理不予许、产品经理不予许…..所以最终我们需要有一个地方能够去抄这些代码,当你积累了住够多的可以抄代码的地方那你其实就已经很NB了,关于shader最好抄代码的地方就是大名鼎鼎的shadertoy,去找自己想要的效果吧,稍微改改直接应用从而实现复杂的效果。上述的烟雾效果函数的GLSL代码如下:

vec3 Effect(float speed, vec2 uv, float time)
{
    float t = mod(time*speed,60.0);
    float rt =0.01*sin(t*0.45);
    mat2 m1 = mat2(cos(rt),-sin(rt),-sin(rt),cos(rt));
    vec2 uva=uv*m1;
    float irt = 0.005* cos(t*0.05);
    mat2 m2 = mat2(sin(irt),cos(irt),-cos(irt),sin(irt));
    for(int i=1;i<40;i+=1)
    {   
        float it = float(i);
        uva*=(m2);
        uva.y+=-1.0+(0.6/it) * cos(t + it*uva.x + 0.5*it)
        *float(mod(it,2.0)==0.0);
        uva.x+=1.0+(0.5/it) * cos(t + it*uva.y + 0.5*(it+15.0));
    }
    //Intensity range from 0 to n;
    float n = 0.5;
    float r = n + n * sin(4.0*uva.x+t);
    float gb = n + n * sin(3.0*uva.y);
    return vec3(r,gb*0.8*r,gb*r);
}

最终颜色应用的代码如下:

float speed = 0.1;
float y = abs(vPosition.y - 1.0);
float x = vPosition.x;
//u_time是js传入的uniform变量,自增的时间浮点值
float t = u_time;
vec2 this_uv = vPosition.xy;
this_uv = 2.0 * this_uv - 1.0;
this_uv *= (0.9 + 0.1*sin(t*0.01));
this_uv.y-=u_time*0.13;
vec3 col = Effect(speed,this_uv,t);
col+= Effect(speed,this_uv*3.0,2.0*t+10.0)*0.3;
col+= Effect(speed,sin(u_time*0.01)*this_uv*2.0,2.0*t+10.0)*0.1;
color = vec4(color.xyz, color.w * y * col.y * 2.0);
float edge_size = 0.2; 
if(x < edge_size){
    color = vec4(color.xyz, color.w * x / edge_size);
}
if(x > 1.0 - edge_size){
    color = vec4(color.xyz, color.w * (1.0 - x) / edge_size);
}
if(y > 1.0 - edge_size){
    color = vec4(color.xyz, color.w * (1.0 - y) / edge_size);
}

总结

虽然DeckGL的文档垃圾的一逼,但是与Mapbox结合之后性能与模拟双精度浮点运算是真的香。

Mapbox结合Deck.gl的高级着色器应用》有32条留言

  1. 大佬,按照你的写法可以出效果,我现在想按建筑高度实现渐变,越高亮度越高,这个shader该怎么写啊

  2. 非常感谢博主的例子,web GL小白也可以照着实现了渐变透明的建筑。另外还想请教下,如果想实现稍微粗一点的“建筑的wireframe”有什么思路么我尝试了
    1. 使用deck.gl的wireframe,但是只有1像素宽
    2. 使用改写shader的方式,感觉建筑侧面是能拿到x坐标的,但屋顶的x不知道该怎么办

    1. 你说的X只是UV坐标,其实世界坐标也能用,wireframe可以自行编写着色器,我之前有一篇帖子的思路可以用,直接通过三角形重心坐标可以搞定

  3. 博主,请教下,如果想要一个渐变透明的建筑顶部,有什么办法实现吗?

    1. 顶部好像没有可以获取的UV坐标,如果我来做的话,构建几何体时我会提前计算好一些属性然后在片源里面做

  4. 博主很赞,给了一个可视化方向上的启发,还是有必要吐槽一下deck文档的,说好了不看源码,不看源码。结果还是要看才能知道它里面怎么玩的。赋上我的代码:
    interface Icoordinates {
    coordinates: number[][]
    }
    interface IGetPolygon {
    geometry: Icoordinates
    }
    interface IHeight {
    height: number
    }
    interface IGetElevation {
    properties: Iheight
    }
    class MapboxDeckAreaLayer extends MapboxLayer {
    formatType?: string;
    constructor(props: any) {
    super({
    id: uuid(14, 14),
    type: PolygonLayer,
    data: props.data.features,
    map: props.map,
    extruded: true,
    pickable: false,
    stroked: true,
    filled: true,
    wireframe: false,
    lineWidthMinPixels: 1,
    getPolygon: d => d.geometry.coordinates[0],
    getElevation: d => (props.formatType ? d.properties.height * 2 : d.properties.height),
    getFillColor: d => d.properties.custom_1 ? [Math.sqrt(d.properties.custom + d.properties.custom), 120, 200, 255] : [238, 224, 229, 250],
    getLineColor: d => d.properties.custom_1 ? [Math.sqrt(d.properties.custom + d.properties.custom), 120, 200, 255] : [238, 224, 229, 250],
    extensions: [new PolyGonFilterByLuma()]
    });
    }
    }

    export default MapboxDeckAreaLayer

  5. 添加完MapboxLayer之后,可以通过map取到Deck的实例的,不用去改deck-utils.js
    const ambientLight = new AmbientLight({
    color: [255, 255, 255],
    intensity: 3.0,
    });
    const light = new LightingEffect({ ambientLight });
    if (map.__deck){
    map.__deck.setProps({ effects: [light] });
    }

    1. 哈哈,你说到点上了,但是如果是在onadd回调里面这样用的话便会有一个问题,光照效果与图层类耦合了,假设这个图层类被多次实例化那么光照效果也会被多次实例化且作用到全局,你这种方案可以正对单个图层应用,常规项目开发往往是同一个图层多次构造不同的数据所以我当时不敢直接这样写。
      假设不写在onadd回调里面则无法确定deck实例是否初始化完成所以我才这样干的

      1. 不需要在onAdd回调里写呀,第一次调用完map.addLayer();之后就可以了。
        另外也可以通过Deck对象的effectManager.effects判断是否添加了全局的环境光。
        我是有点强迫症,不太希望去改deck-utils.js的。。。

  6. 照着博主写的 错不多这样
    //图层拓展器,用于对Shader进行重写与拓展
    class BuildingFilter extends LayerExtension {
    getShaders() {
    return {
    inject: {
    //注入顶点着色器声明
    'vs:#decl': `
    varying vec2 vPosition;
    `,
    //注入顶点着色器,对varying变量赋值
    'vs:#main-end': `
    vPosition = vertexPositions ;
    `,
    //注入片元着色器声明
    'fs:#decl': `
    varying vec2 vPosition;
    //uniform vec3 color;
    uniform float u_time;
    `,
    //重写颜色绘制函数
    'fs:DECKGL_FILTER_COLOR': `
    //color = vec4(color.xyz, color.w * pow(vPosition.y,2.0));

    vec2 uv = -1.0 + 2.0 * vPosition;
    float time = u_time * 0.3 + 0.1*0.01;
    // To create the BG pattern
    vec3 wave_color = vec3(0.0);
    // To create the waves
    float wave_width = 0.01;
    uv.y -= 0.8;
    for(float i = 0.0; i 0.0 ? sin(i * 2.0 + iTime ) : -sin(i * 2.0 + iTime ) ;
    // 控制线条运动方式。 sin 内部控制速度 外面控制幅度
    uv.y += sin(i * 2.3 + u_time ) * 1.0 ;
    // 控制线条宽度
    //wave_width = abs(1.0 / (400.0 * uv.y));
    wave_width = abs(1.0 / (40.0 * uv.y));
    wave_color += vec3(wave_width , wave_width * 3.9, wave_width * 4.5);
    }
    color = vec4(wave_color, ((wave_color.r + wave_color.g + wave_color.b) / 4.0) - 0.2);

    //color = vec4(vec3(iTime , 0, 0), 0.5);

    `
    }

    };
    }
    //传值u_time
    // @ts-ignore
    updateState(params) {
    // @ts-ignore
    const { u_time = 1.0 } = params.props;
    // @ts-ignore
    for (const model of this.getModels()) {
    model.setUniforms({ u_time });
    }
    }
    // @ts-ignore
    getSubLayerProps() {
    // @ts-ignore
    const { u_time = 1.0 } = this.props;
    return {
    u_time
    };
    }
    }

    function animate() {
    time = (time + 0.01);
    if(time > 2){
    //time = 0;
    }

    mylayer1.setProps({ currentTime: time,u_time: time });

    requestAnimationFrame(animate);
    }

    animate();

    1. 我应该是写的比较详细了,如果还是不行结合deck.gl的文档啃一下吧,其实就是在构建layer的时候shadermodule放进去就可以了。
      之前我写的demo早就删除了,本来想重写写一个然后发出来但是没有怎么放在心上。

          1. 我去这么执着的吗?想了一下还是不发源码了,文章都写到这个地步了,照着文章来应该很快能够搭建出来的,而且也没有什么高深的东西,我初衷是分享知识绝不是当奶妈喂食

留下回复