HTML5 Canvas实战
9.1 创建一个WebGL包装器来简化WebGLAPI
阅读(

路径和文本

图形及组合

处理图像和视频

画布变换

动画给画布带来生机

与画布交互:为图形和区域附加事件监听器

创建图表

通过游戏开发来拯救世界

WebGL简介

创建一个WebGL包装器来简化WebGLAPI

如果你很超前,并看了本章的代码,如果对OpenGL或WebGL不是很熟悉的话,你或许感到不知所措,但你理由充分。尽管WebGL非常强大,但如果你是头一回研究它,学习起来将会很费劲。坦白地讲,要实现一个简单的功能,就得写很多行代码。因此,我已经发现使用WebGL包装器,将会非常方便,包装器实际上就是把大块大块的冗长乏味的代码包装到简单的方法中。本章将介绍创建一个简单的WebGLobal包装器的步骤,该包装器将会用于本章各节的所有例子中。我们开始吧!

操作步骤

按照以下步骤,创建一个WebGL包装器对象,来简化WebGL的API:

1. 通过初始化画布上下文,并定义动画属性,来定义WebGL的构造函数:

var WebGL  = function(canvasId){
  this.canvas  = document.getElementById(canvasId);
  this.context  = this.canvas.getContext("experimental-webgl"); 
  this.stage  = undefined;
  //动画
  this.t  =  0;
  this.timeInterval  =  0; 
  this.startTime  =  0; 
  this.lastTime  =  0; 
  this.frame  =  0;
  this.animating  = false;

2. 使用Paul Irish's提供的requestAnimFrame小垫片,来创建一个跨浏览器的requestAnimationFrame函数,该函数使浏览器能够处理动画的FPS:

// provided by Paul Irish 由Paul Irish提供
window.requestAnimFrame  =  (function(callback){
    return window.requestAnimationFrame  ||
    window.webkitRequestAnimationFrame  ||
    window.mozRequestAnimationFrame  ||
    window.oRequestAnimationFrame  ||
    window.msRequestAnimationFrame  ||
    function(callback){
      window.setTimeout(callback,  1000  /  60);
    };
})();

3. 由于Brandon Jones的glMatrix使用的是全局变量,我们可以把这些全局变量封装起来,以便在包装器的外面不能改变它们:

  /*
  * encapsulte mat3, mat4, and vec3 from
  * glMatrix globals
  * 封装glMatrix中的全局变量mat3, mat4, vec3
  */
  this.mat3  = mat3;
  this.mat4  = mat4; 
  this.vec3  = vec3;

4. 定义着色器类型常量,并初始化模型-视图矩阵,透视矩阵,及视口尺寸:

  //着色器类型常量
  this.BLUE_COLOR  = "BLUE_COLOR";
  this.VARYING_COLOR  = "VARYING_COLOR"; 
  this.TEXTURE  = "TEXTURE";
  this.TEXTURE_DIRECTIONAL_LIGHTING  =
  "TEXTURE_DIRECTIONAL_LIGHTING";
  this.shaderProgram  = null;
  this.mvMatrix  = this.mat4.create(); 
  this.pMatrix  = this.mat4.create(); 
  this.mvMatrixStack  =  [];
  this.context.viewportWidth  = this.canvas.width;
  this.context.viewportHeight  = this.canvas.height;

5. 使能深度测试:

  //初始化深度测试
  this.context.enable(this.context.DEPTH_TEST);
};

6. 为上下文属性和画布属性定义getter方法:

WebGL.prototype.getContext  = function() {
  eturn this.context;
};
WebGL.prototype.getCanvas  = function(){
  return this.canvas;
};

7. 定义clear()方法,该方法清除WebGL视口:

 WebGL.prototype.clear  = function(){
  this.context.viewport(0, 0, this.context.viewportWidth, this. context.viewportHeight);
  this.context.clear(this.context.COLOR_BUFFER_BIT  | this. context.DEPTH_BUFFER_BIT);
};

8. 定义setStage()方法:

WebGL.prototype.setStage  = function(func){
  this.stage  = func;
};

9. 定义isAnimating()方法,该方法返回动画是否正在运行:

WebGL.prototype.isAnimating  = function(){
  return this.animating;
};

10. 定义getFrame()方法,该方法返回当前帧号:

WebGL.prototype.getFrame  = function(){
  return this.frame;
};

11. 定义start()方法,该方法用于启动动画:

 WebGL.prototype.start  = function(){
  this.animating  = true;
  var date  = new Date();
  this.startTime  = date.getTime();
  this.lastTime  = this.startTime;
  if  (this.stage  !== undefined)  {
     this.stage();
  }
  this.animationLoop();
};

12. 定义stopAnimation()方法,该方法用于停止动画:

WebGL.prototype.stopAnimation  = function(){
   this.animating  = false;
};

13. 定义getTimeInterval()方法,该方法返回自上一帧被渲染以来的毫秒数:

WebGL.prototype.getTimeInterval  = function(){
  return this.timeInterval;
};

14. 定义getTime()方法,该方法返回自动画启动以来的毫秒数:

WebGL.prototype.getTime  = function(){
  return this.t;
};

15. 定义getFps()函数,该函数返回由浏览器测定的当前FPS的值:

WebGL.prototype.getFps  = function(){
  return this.timeInterval  >  0  ?  1000  / this.timeInterval  :  0;
};

16. 定义animationLoop()方法,该方法负责更新动画属性,绘制画布,并请求一个新的动画帧:

WebGL.prototype.animationLoop  = function(){
  var that  = this;
  this.frame++;
  var date  = new Date();
  var thisTime  = date.getTime();
  this.timeInterval  = thisTime  - this.lastTime; 
  this.t  += this.timeInterval;
  this.lastTime  = thisTime;
  if  (this.stage  !== undefined)  {
    this.stage();
  }
  if  (this.animating)  {
    requestAnimFrame(function(){
      that.animationLoop();
    });
  }
};

17. 定义save()方法,该方法通过把当前状态压入模型-视图矩阵栈,来保存模型-视图矩阵的状态:

WebGL.prototype.save  = function(){
  var copy  = this.mat4.create();
  this.mat4.set(this.mvMatrix, copy);
  this.mvMatrixStack.push(copy);
};

18. 定义restore()方法,该方法恢复模型-视图矩阵的上一个状态:

WebGL.prototype.restore  = function(){
  if  (this.mvMatrixStack.length  ==  0)  {
     throw "Invalid popMatrix!";
  }
  this.mvMatrix  = this.mvMatrixStack.pop();
};

19. 定义getFragmentShaderGLSL()方法,该方法根据shaderType参数,获取GLSL(GL着色器语言)片段代码。实质上,该方法包含由case语句来选择的四个相互独立的GLSL片段着色器程序:

WebGL.prototype.getFragmentShaderGLSL  = function(shaderType){
  switch  (shaderType)  {
    case this.BLUE_COLOR:
      return "#ifdef GL_ES\n"  +
      "precision highp float;\n"  + "#endif\n"  +
      "void main(void)  {\n"  +
      "gl_FragColor  = vec4(0.0,  0.0,  1.0,  1.0);\n" + 
      "}";
    case this.VARYING_COLOR:
      return "#ifdef GL_ES\n"  +
      "precision highp float;\n"  + "#endif\n" +
      "varying vec4 vColor;\n"  +
      "void main(void)  {\n"  +
      "gl_FragColor  = vColor;\n"  +
      "}";
    case this.TEXTURE:
      return "#ifdef GL_ES\n" +
      "precision highp float;\n" + "#endif\n"  +
      "varying vec2 vTextureCoord;\n" + "uniform sampler2D uSampler;\n" + "void main(void) {\n" +
      "gl_FragColor  = texture2D(uSampler,
      vec2(vTextureCoord.s, vTextureCoord.t));\n"  +
      "}";
    case this.TEXTURE_DIRECTIONAL_LIGHTING:
      return "#ifdef GL_ES\n"  +
      "precision highp float;\n"  + "#endif\n"  +
      "varying vec2 vTextureCoord;\n"  +
      "varying vec3 vLightWeighting;\n" + "uniform sampler2D uSampler;\n" + "void main(void) {\n" +
      "vec4 textureColor  = texture2D(uSampler,
      vec2(vTextureCoord.s, vTextureCoord.t));\n"  +
      "gl_FragColor  = vec4(textureColor.rgb  *
      vLightWeighting, textureColor.a);\n"  +
      "}";
  }
};

20. 定义getVertexShaderGLSL()方法,该方法根据shaderType参数得到GLSL顶点的代码:

WebGL.prototype.getVertexShaderGLSL  = function(shaderType) {
  switch  (shaderType)  {
    case this.BLUE_COLOR:
      return "attribute vec3 aVertexPosition;\n"  + "uniform mat4 uMVMatrix;\n"  +
      "uniform mat4 uPMatrix;\n"  + "void main(void)  {\n"  +
      "gl_Position  = uPMatrix  * uMVMatrix  * 
      vec4(aVertexPosition,  1.0);\n"  +
      "}";
    case this.VARYING_COLOR:
      return "attribute vec3 aVertexPosition;\n"  + "attribute vec4 aVertexColor;\n"  +
      "uniform mat4 uMVMatrix;\n"  +
      "uniform mat4 uPMatrix;\n"  +
      "varying vec4 vColor;\n"  + 
      "void main(void)  {\n"  +
      "gl_Position  = uPMatrix  * uMVMatrix  * 
      vec4(aVertexPosition,  1.0);\n"  +
      "vColor  = aVertexColor;\n"  +
      "}";
    case this.TEXTURE:
      return "attribute vec3 aVertexPosition;\n"  + "attribute vec2 aTextureCoord;\n"  + 
      "uniform mat4 uMVMatrix;\n"  +
      "uniform mat4 uPMatrix;\n"  + 
      "varying vec2 vTextureCoord;\n"  + 
      "void main(void)  {\n"  +
      "gl_Position  = uPMatrix  * uMVMatrix  * 
      vec4(aVertexPosition,  1.0);\n"  + 
      "vTextureCoord  = aTextureCoord;\n"  +
      "}";
    case this.TEXTURE_DIRECTIONAL_LIGHTING:
      return "attribute vec3 aVertexPosition;\n"  +
      "attribute vec3 aVertexNormal;\n"  +
      "attribute vec2 aTextureCoord;\n"  + 
      "uniform mat4 uMVMatrix;\n"  +
      "uniform mat4 uPMatrix;\n"  +
      "uniform mat3 uNMatrix;\n"  + 
      "uniform vec3 uAmbientColor;\n"  +
      "uniform vec3 uLightingDirection;\n"  +
      "uniform vec3 uDirectionalColor;\n"  +
      "uniform bool uUseLighting;\n"  +
      "varying vec2 vTextureCoord;\n"  +
      "varying vec3 vLightWeighting;\n"  + 
      "void main(void)  {\n"  +
      "gl_Position  = uPMatrix  * uMVMatrix  * vec4(aVertexPosition,  1.0);\n"  + 
      "vTextureCoord  = aTextureCoord;\n"  + "if  (!uUseLighting)  {\n"  +
      "vLightWeighting  = vec3(1.0,  1.0,  1.0);\n"  + "} else  {\n"  +
      "vec3 transformedNormal  = uNMatrix  * aVertexNormal;\n"  +
        "float directionalLightWeighting  =
      max(dot(transformedNormal, uLightingDirection),  0.0);\n"  +
      "vLightWeighting  = uAmbientColor  + uDirectionalColor  *
      directionalLightWeighting;\n"  +
      "}\n"  + 
      "}";
  }
};

21. 定义initShaders()方法,该方法根据shaderType参数初始化合适的着色器:

WebGL.prototype.initShaders  = function(shaderType){
  this.initPositionShader();
  switch  (shaderType)  {
    case this.VARYING_COLOR:
      this.initColorShader(); 
      break;
    case this.TEXTURE:
      this.initTextureShader(); 
      break;
    case this.TEXTURE_DIRECTIONAL_LIGHTING:
      this.initTextureShader();
      this.initNormalShader();
      this.initLightingShader(); 
      break;
  }
};

22. 定义setShaderProgram()方法,该方法根据shaderType参数设置着色器程序:

WebGL.prototype.setShaderProgram  = function(shaderType){
  var fragmentGLSL   = this.getFragmentShaderGLSL(shaderType);
  var vertexGLSL     = this.getVertexShaderGLSL(shaderType);
  var fragmentShader = this.context.createShader(this.context.FRAGMENT_SHADER);
  this.context.shaderSource(fragmentShader, fragmentGLSL); 
  this.context.compileShader(fragmentShader);
  var vertexShader  = this.context.createShader(this.context.VERTEX_SHADER);
  this.context.shaderSource(vertexShader, vertexGLSL); 
  this.context.compileShader(vertexShader);
  this.shaderProgram  = this.context.createProgram();
  this.context.attachShader(this.shaderProgram, vertexShader);
  this.context.attachShader(this.shaderProgram, fragmentShader); 
  this.context.linkProgram(this.shaderProgram);
  if  (!this.context.getProgramParameter(this.shaderProgram, this.context.LINK_STATUS))  {
    alert("Could not initialize shaders");
  }
  this.context.useProgram(this.shaderProgram);
  // 一旦着色器程序加载完成,就该初始化着色器了
  this.initShaders(shaderType);
};

23. 定义perspective()方法,该方法封装glMatrix中操作透视矩阵的perspective()方法:

WebGL.prototype.perspective  = function(viewAngle, minDist, maxDist){
  this.mat4.perspective(viewAngle, this.context.viewportWidth  / 
  this.context.viewportHeight, minDist, maxDist, this.pMatrix);
};

24. 定义identity()方法,该方法封装glMatrix中操作模型-视图矩阵的identity()方法:

WebGL.prototype.identity  = function(){ 
  this.mat4.identity(this.mvMatrix);
};

25. 定义translate()方法,该方法封装glMatrix中操作模型-视图矩阵的translate()方法:

WebGL.prototype.translate  = function(x, y, z){
  this.mat4.translate(this.mvMatrix,  [x, y, z]);
};

26. 定义rotate()方法,该方法封装glMatrix中操作模型-视图矩阵的rotate()方法:

WebGL.prototype.rotate  = function(angle, x, y, z){
  this.mat4.rotate(this.mvMatrix, angle,  [x, y, z]);
};

27. 定义initPositionShader()方法,该方法初始化被用于位置缓冲区的位置着色器:

WebGL.prototype.initPositionShader  = function(){
  this.shaderProgram.vertexPositionAttribute  = this.context. getAttribLocation(this.shaderProgram, "aVertexPosition");
  this.context.enableVertexAttribArray(this.shaderProgram.vertexPositionAttribute);
  this.shaderProgram.pMatrixUniform  = this.context.getUniformLocation(this.shaderProgram, "uPMatrix");
  this.shaderProgram.mvMatrixUniform  = this.context.getUniformLocation(this.shaderProgram, "uMVMatrix");
};

28. 定义initColorShader()方法,该方法初始化被用于颜色缓冲区的颜色着色器:

WebGL.prototype.initColorShader = function(){
  this.shaderProgram.vertexColorAttribute = this.context.getAttribLocation(this.shaderProgram, "aVertexColor");
  this.context.enableVertexAttribArray(this.shaderProgram.vertexColorAttribute);
};

29. 定义initTextureShader()方法,该方法初始化被用于纹理缓冲区的纹理着色器:

WebGL.prototype.initTextureShader  = function(){
  this.shaderProgram.textureCoordAttribute = this.context. getAttribLocation(this.shaderProgram, "aTextureCoord");
  this.context.enableVertexAttribArray(this.shaderProgram.textureCoordAttribute);
  this.shaderProgram.samplerUniform  = this.context.getUniformLocation(this.shaderProgram, "uSampler");
};

30. 定义initNormalShader()方法,该方法初始化被用于法线缓冲区的法线着色器:

WebGL.prototype.initNormalShader  = function(){
  this.shaderProgram.vertexNormalAttribute  = this.context. getAttribLocation(this.shaderProgram, "aVertexNormal");
  this.context.enableVertexAttribArray(this.shaderProgram.vertexNormalAttribute);
  this.shaderProgram.nMatrixUniform  = this.context.getUniformLocation(this.shaderProgram, "uNMatrix");
};

31. 定义initLightingShader()方法,该方法初始化环境光着色器和平行光着色器:

WebGL.prototype.initLightingShader  = function(){
  this.shaderProgram.useLightingUniform  = this.context.
  getUniformLocation(this.shaderProgram, "uUseLighting");
  this.shaderProgram.ambientColorUniform  = this.context.getUniformLocation(this.shaderProgram, "uAmbientColor");
  this.shaderProgram.lightingDirectionUniform  = this.context.getUniformLocation(this.shaderProgram, "uLightingDirection");
  this.shaderProgram.directionalColorUniform  = this.context.getUniformLocation(this.shaderProgram, "uDirectionalColor");
};

32. 定义initTexture()方法,该方法封装初始化WebGL纹理对象所需要的WebGL API代码:

WebGL.prototype.initTexture  = function(texture){
  this.context.pixelStorei(this.context.UNPACK_FLIP_Y_WEBGL, true);
  this.context.bindTexture(this.context.TEXTURE_2D, texture);
  this.context.texImage2D(this.context.TEXTURE_2D,0,this.context.RGBA, this.context.RGBA,this.context.UNSIGNED_BYTE,texture.image);
  this.context.texParameteri(this.context.TEXTURE_2D,this.context.TEXTURE_MAG_FILTER,this.context.NEAREST);
  this.context.texParameteri(this.context.TEXTURE_2D,this.context.TEXTURE_MIN_FILTER,this.context.LINEAR_MIPMAP_NEAREST);
  this.context.generateMipmap(this.context.TEXTURE_2D);
  this.context.bindTexture(this.context.TEXTURE_2D, null);
};

33. 定义createArrayBuffer()方法,该方法封装创建数组缓冲所需要的WebGL API代码:

WebGL.prototype.createArrayBuffer  = function(vertices){
  var buffer  = this.context.createBuffer();
  buffer.numElements  = vertices.length;
  this.context.bindBuffer(this.context.ARRAY_BUFFER, buffer); 
  this.context.bufferData(this.context.ARRAY_BUFFER, new  Float32Array(vertices), this.context.STATIC_DRAW);
  return buffer;
};

34. 定义createElementArrayBuffer()方法,该方法封装创建元素数组缓冲区所需要的WebGL API代码:

WebGL.prototype.createElementArrayBuffer  = function(vertices){
  var buffer  = this.context.createBuffer();
  buffer.numElements  = vertices.length;
  this.context.bindBuffer(this.context.ELEMENT_ARRAY_BUFFER, buffer);
  this.context.bufferData(this.context.ELEMENT_ARRAY_BUFFER,new Uint16Array(vertices),this.context.STATIC_DRAW);
  return buffer;
};

35. 定义pushPositionBuffer()方法,该方法把位置缓冲区发送到显卡:

WebGL.prototype.pushPositionBuffer  = function(buffers){ 
  this.context.bindBuffer(this.context.ARRAY_BUFFER,buffers.positionBuffer);
  this.context.vertexAttribPointer(this.shaderProgram.vertexPositionAttribute,3,this.context.FLOAT,false,0,0);
};

36. 定义pushColorBuffer()方法,该方法把颜色缓冲区发送到显卡:

WebGL.prototype.pushColorBuffer  = function(buffers){
  this.context.bindBuffer(this.context.ARRAY_BUFFER, buffers.colorBuffer);
  this.context.vertexAttribPointer(this.shaderProgram.vertexColorAttribute,  4, this.context.FLOAT, false,  0,  0);
};

37. 定义pushTextureBuffer()方法,该方法把纹理缓冲区发送到显卡:

WebGL.prototype.pushTextureBuffer  = function(buffers, texture){
  this.context.bindBuffer(this.context.ARRAY_BUFFER,buffers.textureBuffer);
  this.context.vertexAttribPointer(this.shaderProgram.textureCoordAttribute, 2,this.context.FLOAT, false, 0, 0);
  this.context.activeTexture(this.context.TEXTURE0);
  this.context.bindTexture(this.context.TEXTURE_2D, texture);
  this.context.uniform1i(this.shaderProgram.samplerUniform,  0);
};

38. 定义pushIndexBuffer()方法,该方法把索引缓冲区发送到显卡:

WebGL.prototype.pushIndexBuffer  = function(buffers){
  this.context.bindBuffer(this.context.ELEMENT_ARRAY_BUFFER, buffers.indexBuffer);
};

39. 定义pushNormalBuffer()方法,该方法把法线缓冲区发送到显卡:

WebGL.prototype.pushNormalBuffer  = function(buffers){
  this.context.bindBuffer(this.context.ARRAY_BUFFER, buffers. normalBuffer);
  this.context.vertexAttribPointer(this.shaderProgram.vertexNormalAttribute,  3, this.context.FLOAT, false,  0,  0);
};

40. 定义setMatrixUniforms()方法,该方法封装创建矩阵uniforms所需要的WebGL API代码:

WebGL.prototype.setMatrixUniforms  = function(){
  this.context.uniformMatrix4fv(this.shaderProgram.pMatrixUniform, false, this.pMatrix);
  this.context.uniformMatrix4fv(this.shaderProgram.mvMatrixUniform, false, this.mvMatrix);
  var normalMatrix  = this.mat3.create();
  this.mat4.toInverseMat3(this.mvMatrix, normalMatrix); 
  this.mat3.transpose(normalMatrix);
  this.context.uniformMatrix3fv(this.shaderProgram.nMatrixUniform, false, normalMatrix);
};

41. 定义drawElements()方法,该方法封装根据索引缓冲区绘制非三角位置缓冲区所需要的WebGL API代码:

WebGL.prototype.drawElements  = function(buffers){
  this.setMatrixUniforms();
  //绘制元素
  this.context.drawElements(this.context.TRIANGLES, buffers.indexBuffer.numElements, this.context.UNSIGNED_SHORT,  0);
};

42. 定义drawArrays()方法,该方法封装绘制三角形位置缓冲区所需要的WebGL API代码:

WebGL.prototype.drawArrays  = function(buffers){
  this.setMatrixUniforms();
  //绘制数组
  this.context.drawArrays(this.context.TRIANGLES,  0, buffers. positionBuffer.numElements  /  3);
};

43. 定义enableLighting()方法,该方法封装使能光照所需要的WebGL API代码:

WebGL.prototype.enableLighting  = function(){
  this.context.uniform1i(this.shaderProgram.useLightingUniform, true);
};

44. 定义setAmbientLighting()方法,该方法封装设置环境光所需要的WebGL API代码:

WebGL.prototype.setAmbientLighting  = function(red, green, blue){
  this.context.uniform3f(this.shaderProgram.ambientColorUniform,parseFloat(red), parseFloat(green), parseFloat(blue));
};

45. 定义setDirectionalLighting()方法,该方法封装设置平行光所需要的WebGL API代码:

WebGL.prototype.setDirectionalLighting  = function(x, y, z, red, green, blue){
  // 平行光
  var lightingDirection  =  [x, y, z];
  var adjustedLD  = this.vec3.create();
  this.vec3.normalize(lightingDirection, adjustedLD); this.vec3.scale(adjustedLD,  -1);
  this.context.uniform3fv(this.shaderProgram. lightingDirectionUniform, adjustedLD);
  //平行光颜色
  this.context.uniform3f(this.shaderProgram.
  directionalColorUniform, parseFloat(red), parseFloat(green), parseFloat(blue));
};

工作原理

WebGL包装器对象的思想是,处理WebGL API没有提供的一些东西,并把做简单事情所需要的冗长代码封装起来。

WebGL的API中,没有内置两个主要组件——矩阵变换运算和着色器程序。本章,我们将使用Brandon Jones专门为WebGL构建的,一个很好用的矩阵库glMatrix,来处理所有向量操作。由于该库缺少对着色器程序的支持,我们的WebGL包装器对象中,纳入了预构建的GLSL着色器程序。着色器程序使用GLSL语言编写,GLSL是OpenGL Shading Language的缩写,用于以编程的方式来定义如何渲染顶点和片段。顶点着色器操作组成3D模型的每一个顶点,片段着色器操作由栅格化产生的每一个片段。要使用着色器程序,实际上我们必须把GLSL代码以字符串形式传递给WebGL API。

除了包装器的方法外,WebGL包装器对象还包括我们在第5章 动画为画布带来生机中讲的动画方法。

WebGL包装器对象的其余大多数方法,只是简单封装把缓冲区发送到显卡并绘制结果所需要的代码块。接下来的五节,我们将深入研究每类缓冲区,包括位置缓冲区、颜色缓冲区、索引缓冲区、纹理缓冲区、法线缓冲区。

了解更多

对WebGL和OpenGL更深一步的研究,请查阅这两个非常棒的资源:

  • http://learningwebgl.com/
  • http://nehe.gamedev.net/

相关参考

  • 附录A,检测画布支持

如果本教程对您帮助很大,请随意打赏。您的支持,将鼓励我们提供更好的教程!

← 键盘方向键翻页 →
返回顶部 手机访问 关注微信 返回底部

扫码访问歪脖网

随时随地,想看就看

关注歪脖网微信

分享 web 知识、交流 web 经验