• 初见物理引擎库Cannon.js


    0 前言

    本文是“初见物理引擎库Cannon.js”系列的第一篇文章,在本文中主要讲解Cannon.js的基本使用。

    1 物理引擎库

    1.1 常见的物理引擎库

    物理引擎库分为2D和3D,本文中仅讨论3D,目前市面上开源的物理引擎有如下几种:

    • Ammo.js:基于著名的物理引擎 Bullet physics engine,原为C++实现,作者使用Emscripten重写为JavaScript实现;
    • Physijs:基于Ammo.js的API的封装,做了部分优化;
    • Cannon.js:基于Ammo.js的API的封装,做了部分优化;
    • Oimo.js:基于OimoPhysics实现。

    1.2 物理引擎库对比

    作者对以上4种物理引擎库都做了简单的尝试,粗略的总结如下,表中不包含特性支持,有兴趣的读者可以查阅其README:

    物理引擎库近期更新时间文档 & API易用性ES6 Module
    Ammo.js5个月内有更新,但都不是核心代码更新作者推荐直接学习Bullet physics engine的文档,学习成本很高官方不支持
    Physijs在2.0分支中,上次更新在6年前文档齐全,API易用性较好官方不支持
    Cannon.js上次更新在7年前文档齐全,API易用性很好官方不支持
    Oimo.js上次更新在4年前文档不全,API易用性较好官方不支持

    1.3 小结

    物理引擎还有一项重要的指标就是性能,但由于作者时间有限,无法完成各项测试进行评估,欢迎各位补充。

    由于上述几种物理引擎库的创建时间都比较久远,他们都不支持ES6 Module,近些年也没有什么更新,但好在已经有不少ES6改写的分支了,其中完成度最高的就是基于Cannon.js的cannon-es,基于以上对比,作者最终选择了使用Cannon.js。

    2 Cannon.js的基本使用

    本文基于Three.js以小球在地板上回弹为例一步一步地讲解Cannon.js的基本使用方法:

    注意:下文中将不会提及Three.js中的初始化和渲染代码,其完整代码可以在文末找到。
    在这里插入图片描述

    2.1 在线地址

    [ Live Demo ]:Cannon.js (syzdev.cn)

    2.2 创建物理世界

    以现实世界为例,在现实世界中,在地球的任何地方都存在重力加速度和其方向大小,世界上的任何两个物体间相互接触或碰撞都会表现出各种不同的结果,如一个乒乓球自由下落在瓷钻上和木板上的结果是不一样的。

    类似的,使用Cannon.js创建一个物理世界也是遵循着现实世界的规则,只不过在物理世界中,我们可以自由的设定这些规则。在设定这些规则之前,先要初始化一个Cannon.js物理世界:

    let cannon = {
      world: null,
    }
    // 初始化Cannon中的物理世界World
    cannon.world = new CANNON.World()
    
    • 1
    • 2
    • 3
    • 4
    • 5

    物理世界中是要有重力加速度的,需要为其设定大小及方向:

    // 设置物理世界中的重力,设置为y轴负方向的-9.8 m/s²,模拟真实世界
    cannon.world.gravity.set(0, -9.8, 0)
    
    • 1
    • 2

    在物理世界中,两种物体进行碰撞时该遵循什么规则?如表面接触时的摩擦力、自由下落时的弹性系数。在Cannon.js中,是用不同的材质表示不同类型的物体的,再将两个材质进行关联并设定相应的规则即可,为简单起见,例子中使用Cannon.js中的两个默认材质进行关联:

    // 声明默认材质
    const cannonDefaultMaterial = new CANNON.Material()
    // 创建两个默认材质的关联材质
    const cannonDefaultCantactMaterial = new CANNON.ContactMaterial(
      cannonDefaultMaterial,
      cannonDefaultMaterial,
      {
        friction: 0.5, // 摩擦力
        restitution: 0.7, // 弹性系数
      }
    )
    // 将两个默认材质添加到物理世界world中
    cannon.world.addContactMaterial(cannonDefaultCantactMaterial)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    完成上述步骤后,一个基本的Cannon.js物理世界就创建好了。

    2.3 创建物体

    要在Cannon.js中创建一个物体,通常要声明如下属性:

    • 形状:表示在物理世界中物体的形状,这个很好理解,一个地板就是一个平面Plane,一个小球就是一个球体Sphere
    • 材质:如2.1节中所述,在物理世界中使用不同的材质表示不同的物体,使用关联材质决定两个不同材质之间接触的规则;
    • 质量:和现实世界中一样,两个物体以一定的速度相撞,质量小的物体会被弹开的更远,将该值设置为0则表示静止的物体,静止的物体无论如何被碰撞都不会改变其位置;
    • 位置:物理世界中物体的初始位置。

    另外需要注意的是,要创建一个物体,需要创建一个Cannon.js中的物体Body,还要创建一个Three.js中的物体Mesh,原因在于Cannon.js中的物体只负责计算物体之间的物理变换,并不负责把物体显示在场景中;要将物体显示在场景中,并在物体相互作用时显示对应的变换,则需要使用Three.js。

    2.3.1 创建地板

    创建一个Cannon.js中的地板的代码如下:

    // 1 创建Cannon中的地板刚体
    // 1.0 创建地板刚体形状
    let cannonPlaneShape = new CANNON.Plane()
    // 1.1 创建地板刚体的材质,默认材质
    // let cannonPlaneMaterial = new CANNON.Material()
    let cannonPlaneMaterial = cannonDefaultMaterial
    // 1.2 创建地板刚体的质量mass,质量为0的物体为静止的物体
    let cannonPlaneMass = 0
    // 1.3 创建地板刚体的位置position,坐标原点
    let cannonPlanePosition = new CANNON.Vec3(0, 0, 0)
    // 1.4 创建地板刚体的Body
    let cannonPlaneBody = new CANNON.Body({
      mass: cannonPlaneMass,
      position: cannonPlanePosition,
      shape: cannonPlaneShape,
      material: cannonPlaneMaterial,
    })
    // 1.5 旋转地板刚体Body,使其垂直与y轴
    // setFromAxisAngle方法第一个参数是旋转轴,第二个参数是角度
    cannonPlaneBody.quaternion.setFromAxisAngle(
      new CANNON.Vec3(1, 0, 0),
      -Math.PI / 2
    )
    // 1.6 将cannonPlaneBody添加到物理场景world中
    cannon.world.addBody(cannonPlaneBody)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    创建一个Three.js中的地板的代码如下:

    // 2 创建Three中的地板网格
    // 2.0 创建Three中的地板网格形状
    let threePlaneGeometry = new THREE.PlaneGeometry(20, 20, 20)
    // 2.1 创建地板网格的材质
    let threePlaneMaterial = new THREE.MeshLambertMaterial({
      color: 0xa5a5a5,
      side: THREE.DoubleSide,
    })
    // 2.2 创建地板网格的mesh
    let threePlaneMesh = new THREE.Mesh(
      threePlaneGeometry,
      threePlaneMaterial
    )
    // 2.3 设置地板网格的旋转
    threePlaneMesh.rotation.x = -Math.PI / 2
    // 2.4 开启地表网格接收光照阴影
    threePlaneMesh.receiveShadow = true
    // 2.5 设置地板网格的位置,坐标原点
    threePlaneMesh.position.set(0, 0, 0)
    // 2.6 设置地板网格的大小缩放
    threePlaneMesh.scale.set(2, 2, 2)
    // 2.7 将threePlaneMesh添加到Three场景scene中
    three.scene.add(threePlaneMesh)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    2.3.2 创建小球

    小球和地板不同,小球是一个动态的物体,会随着与地板之间的碰撞发生位置和旋转方向的变换,因此,小球的质量mass不为0;由于小球的位置和旋转方向是会变换的,所以我们不得不在每次渲染场景时更新该变换,我们将创建一个数组用于保存这些需要变换的物体,并在每次帧渲染时更新其位置和旋转方向,定义的数组如下:

    const MeshBodyToUpdate = []
    
    • 1

    MeshBodyToUpdate为一个对象数组,数组中的每一个对象为Three.js中的Mesh和Cannon.js中的Body,添加的形式如下:

    MeshBodyToUpdate.push({
      mesh: threeMesh,
      body: CannonBody,
    })
    
    • 1
    • 2
    • 3
    • 4

    render函数中遍历该数组,将Three中的Mesh的位置和旋转更新为Cannon中的Body的位置和旋转,该内容会在下一章中具体讲解。

    创建一个Cannon.js中的小球的代码如下:

    // 1 创建Cannon中的球体刚体
    // 1.1 创建球体刚体形状,参数为球体的半径
    let cannonSphereShape = new CANNON.Sphere(1)
    // 1.2 创建球体刚体的材质,默认材质
    // let cannonSphereMaterial = new CANNON.Material()
    let cannonSphereMaterial = cannonDefaultMaterial
    // 1.3 创建球体刚体的质量mass,单位为kg
    let cannonSphereMass = 5
    // 1.4 创建球体刚体的位置position
    let cannonSpherePosition = new CANNON.Vec3(0, 10, 0)
    // 1.5 创建球体刚体的Body
    let cannonSphereBody = new CANNON.Body({
      mass: cannonSphereMass,
      shape: cannonSphereShape,
      position: cannonSpherePosition,
      material: cannonSphereMaterial,
    })
    // 1.6 将cannonSphereBody添加到物理场景world中
    cannon.world.addBody(cannonSphereBody)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    创建一个Three.js中的小球的代码如下:

    // 2 创建Three中的球体网格
    // 2.1 创建球体网格的形状
    let threeSphereGeometry = new THREE.SphereGeometry(1, 32, 32)
    // 2.2 创建球体网格的材质
    let threeSphereMaterial = new THREE.MeshStandardMaterial({
      color: 0xFFB6C1,
    })
    // 2.3 创建球体网格的Mesh
    let threeSphereMesh = new THREE.Mesh(
      threeSphereGeometry,
      threeSphereMaterial
    )
    // 2.4 设置球体网格投射光照阴影
    threeSphereMesh.castShadow = true
    // 2.5 将threeSphereMesh添加到Three场景的scene中
    three.scene.add(threeSphereMesh)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    将Three.js中的Mesh和Cannon.js中的Body添加到对象数组MeshBodyToUpdate中:

    MeshBodyToUpdate.push({
      body: cannonSphereBody,
      mesh: threeSphereMesh,
    })
    
    • 1
    • 2
    • 3
    • 4

    完成以上步骤,则一个动态的小球就创建好了,一定要注意创建一个动态的物体要比创建一个静态的物体多一个步骤,即将MeshBody添加到对象数组MeshBodyToUpdate中。

    2.4 更新场景

    设置更新物理世界cannon.world的步长timestep,这里选用60Hz的速度,即1.0 / 60.0

    cannon.world.step(1.0 / 60.0)
    
    • 1

    更新MeshBodyToUpdate中的MeshBody,使其位置和旋转方向同步:

    for (const object of MeshBodyToUpdate) {
      object.mesh.position.copy(object.body.position) // 位置同步
      object.mesh.quaternion.copy(object.body.quaternion) // 旋转方向同步
    }
    
    • 1
    • 2
    • 3
    • 4

    在2.2节中提到,创建一个物体,既要创建一个Cannon.js物体Body,用于计算物体之间的物理变换,并不负责把物体显示在场景中;还要创建一个Three.js物体,用于将物体显示在场景中,并在物体相互作用时显示对应的变换。上述代码完成的事情其实就是将Cannon.js的物体Body计算的变换(位置和旋转方向)应用到Three.js的物体Mesh上。

    3 完整代码

    /**
     * MeshBodyToUpdate为一个对象数组
     * 数组中的每一个对象为Three中的Mesh和Cannon中的Body
     * 添加的形式如下
     * MeshBodyToUpdate.push({
     *   mesh: mesh,
     *   body: body,
     * })
     * 在render函数中遍历该数组,将Three中的Mesh的位置和旋转更新为Cannon中的Body的位置和旋转
     */
    const MeshBodyToUpdate = []
    
    /**
     * 声明默认材质
     * 用于初始化Cannon时创建关联材质
     */
    const cannonDefaultMaterial = new CANNON.Material()
    
    /**
     * 初始化Three的参数,为了将Three和Cannon分离
     * 用three对象来保存Three中的场景scene、相机camera和渲染器renderer
     */
    let three = {
      scene: null,
      camera: null,
      renderer: null,
    }
    
    /**
     * 初始化Cannon参数
     */
    let cannon = {
      world: null,
    }
    
    /**
     * @description: 初始化Three场景
     */
    const initThree = () => {
      // 1 初始化Three场景
      three.scene = new THREE.Scene()
      // 2 初始化Three相机
      /**
       * THREE.PerspectiveCamera 初始化一个Three透视相机
       * fov:摄像机视锥体垂直视野角度
       * aspect:相机场景长宽比
       * near:摄像机视锥体近端面
       * far:摄像机视锥体远端面
       */
      three.camera = new THREE.PerspectiveCamera(
        60,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      )
      three.camera.position.set(20, 20, 20) // 设置相机位置
      three.camera.lookAt(three.scene.position) // 设置相机视角朝向Three场景scene
    
      // three.scene.add(new THREE.AxesHelper(20)) // 添加场景坐标轴
    
      // 3 初始化Three渲染器
      // three.renderer = new THREE.WebGLRenderer({ antialias: true }) // 初始化Three渲染器
      three.renderer = new THREE.WebGLRenderer() // 初始化Three渲染器
      three.renderer.setClearColor(0xffffff, 1.0) // 设置渲染背景色
      three.renderer.shadowMap.enabled = true // 开启场景光照阴影效果
      three.renderer.setSize(window.innerWidth, window.innerHeight) // 设置渲染范围大小
    
      // 4 初始化光源
      three.scene.add(new THREE.AmbientLight(0x404040)) // 初始化坏境光
      // 初始化聚光源
      let spotLight = new THREE.SpotLight(0x999999)
      spotLight.position.set(-10, 30, 20) // 设置聚光源的位置
      spotLight.castShadow = true // 开启聚光源投射阴影
      // spotLight.distance = 1000000000
      three.scene.add(spotLight)
    
      // 5 添加Three到DOM下
      document
        .getElementById('threeContainer')
        .appendChild(three.renderer.domElement)
        
      // OrbitControls轨道控制器
      let controls = new OrbitControls(three.camera, three.renderer.domElement)
    }
    
    /**
     * @description: 初始化Cannon物理场景
     */
    const initCannon = () => {
      // 1 初始化Cannon中的物理世界World
      cannon.world = new CANNON.World()
    
      // 2 设置物理世界中的重力,设置为y轴负方向的-9.8 m/s²,模拟真实世界
      cannon.world.gravity.set(0, -9.8, 0)
    
      // 3 设置物理世界中的碰撞检测模式
      cannon.world.broadphase = new CANNON.NaiveBroadphase()
    
      // 4 设置物理世界中的联系材质,用于判断物体之间的接触关系
      // 4.1 声明混泥土材质
      // const cannonConcreteMaterial = new CANNON.Material('concrete')
      // 4.2 声明塑料材质
      // const cannonPlasticMaterial = new CANNON.Material('plastic')
      // 4.3 声明默认材质
      // const cannonDefaultMaterial = new CANNON.Material()
      // 4.4 创建两个默认材质的关联材质
      const cannonDefaultCantactMaterial = new CANNON.ContactMaterial(
        cannonDefaultMaterial,
        cannonDefaultMaterial,
        {
          friction: 0.5,
          restitution: 0.7,
        }
      )
      // 4.5 将两个默认材质添加到物理世界world中
      cannon.world.addContactMaterial(cannonDefaultCantactMaterial)
    }
    
    /**
     * @description: 创建地板
     * 由于Plane不需要改变位置,所以不需要添加至MeshBodyToUpdate进行位置旋转同步
     */
    const createPlane = () => {
      // 1 创建Cannon中的地板刚体
      // 1.0 创建地板刚体形状
      let cannonPlaneShape = new CANNON.Plane()
      // 1.1 创建地板刚体的材质,默认材质
      // let cannonPlaneMaterial = new CANNON.Material()
      let cannonPlaneMaterial = cannonDefaultMaterial
      // 1.2 创建地板刚体的质量mass,质量为0的物体为静止的物体
      let cannonPlaneMass = 0
      // 1.3 创建地板刚体的位置position,坐标原点
      let cannonPlanePosition = new CANNON.Vec3(0, 0, 0)
      // 1.4 创建地板刚体的Body
      let cannonPlaneBody = new CANNON.Body({
        mass: cannonPlaneMass,
        position: cannonPlanePosition,
        shape: cannonPlaneShape,
        material: cannonPlaneMaterial,
      })
      // 1.5 旋转地板刚体Body,使其垂直与y轴
      // setFromAxisAngle方法第一个参数是旋转轴,第二个参数是角度
      cannonPlaneBody.quaternion.setFromAxisAngle(
        new CANNON.Vec3(1, 0, 0),
        -Math.PI / 2
      )
      // 1.6 将cannonPlaneBody添加到物理场景world中
      cannon.world.addBody(cannonPlaneBody)
    
      // 2 创建Three中的地板网格
      // 2.0 创建Three中的地板网格形状
      let threePlaneGeometry = new THREE.PlaneGeometry(20, 20, 20)
      // 2.1 创建地板网格的材质
      let threePlaneMaterial = new THREE.MeshLambertMaterial({
        color: 0xa5a5a5,
        side: THREE.DoubleSide,
      })
      // 2.2 创建地板网格的mesh
      let threePlaneMesh = new THREE.Mesh(
        threePlaneGeometry,
        threePlaneMaterial
      )
      // 2.3 设置地板网格的旋转
      threePlaneMesh.rotation.x = -Math.PI / 2
      // 2.4 开启地表网格接收光照阴影
      threePlaneMesh.receiveShadow = true
      // 2.5 设置地板网格的位置,坐标原点
      threePlaneMesh.position.set(0, 0, 0)
      // 2.6 设置地板网格的大小缩放
      threePlaneMesh.scale.set(2, 2, 2)
      // 2.7 将threePlaneMesh添加到Three场景scene中
      three.scene.add(threePlaneMesh)
    }
    
    /**
     * @description: 创建球体
     * 球体是含有质量mass的,所以需要添加至MeshBodyToUpdate进行位置旋转同步
     */
    const createSphere = () => {
      // 1 创建Cannon中的球体刚体
      // 1.1 创建球体刚体形状,参数为球体的半径
      let cannonSphereShape = new CANNON.Sphere(1)
      // 1.2 创建球体刚体的材质,默认材质
      // let cannonSphereMaterial = new CANNON.Material()
      let cannonSphereMaterial = cannonDefaultMaterial
      // 1.3 创建球体刚体的质量mass,单位为kg
      let cannonSphereMass = 5
      // 1.4 创建球体刚体的位置position
      let cannonSpherePosition = new CANNON.Vec3(0, 10, 0)
      // 1.5 创建球体刚体的Body
      let cannonSphereBody = new CANNON.Body({
        mass: cannonSphereMass,
        shape: cannonSphereShape,
        position: cannonSpherePosition,
        material: cannonSphereMaterial,
      })
      // 1.6 将cannonSphereBody添加到物理场景world中
      cannon.world.addBody(cannonSphereBody)
    
      // 2 创建Three中的球体网格
      // 2.1 创建球体网格的形状
      let threeSphereGeometry = new THREE.SphereGeometry(1, 32, 32)
      // 2.2 创建球体网格的材质
      let threeSphereMaterial = new THREE.MeshStandardMaterial({
        color: 0x33aaaa,
      })
      // 2.3 创建球体网格的Mesh
      let threeSphereMesh = new THREE.Mesh(
        threeSphereGeometry,
        threeSphereMaterial
      )
      // 2.4 设置球体网格投射光照阴影
      threeSphereMesh.castShadow = true
      // 2.5 将threeSphereMesh添加到Three场景的scene中
      three.scene.add(threeSphereMesh)
    
      // 3 将cannonSphereBody和threeSphereMesh添加到MeshBodyToUpdate中
      MeshBodyToUpdate.push({
        body: cannonSphereBody,
        mesh: threeSphereMesh,
      })
    }
    
    /**
     * @description: 循环渲染场景
     */
    const render = () => {
      /**
       * 设置更新物理世界world的步长timestep
       * 这里选用60Hz的速度,即1.0 / 60.0
       */
      cannon.world.step(1.0 / 60.0)
    
      // 更新MeshBodyToUpdate中的Mesh和Body,使其位置和旋转同步
      for (const object of MeshBodyToUpdate) {
        object.mesh.position.copy(object.body.position)
        object.mesh.quaternion.copy(object.body.quaternion)
      }
    
      requestAnimationFrame(render)
      three.renderer.render(three.scene, three.camera)
    }
    
    initThree()
    initCannon()
    createPlane()
    createSphere()
    render()
    
    // 改变窗口大小重新渲染场景
    window.addEventListener('resize', () => {
      three.camera.aspect = window.innerWidth / window.innerHeight
      three.camera.updateProjectionMatrix()
      three.renderer.setSize(window.innerWidth, window.innerHeight)
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
  • 相关阅读:
    Flink系列文档-(YY04)-Flink编程基础API-Transformation算子
    【Flutter】下载安装Flutter并使用学习dart语言
    如何写一个合格的API文档
    CSS 响应式设计:网格视图
    飞天使-学以致用-devops知识点3-安装jenkins
    uni-app 如何更换tabbar里面的图标
    艾美捷热转移稳定性检测试剂盒:简单、灵敏、均匀的荧光测定法
    【Git】第一篇:Git安装(centos)
    论文复现|Panoptic Deeplab(全景分割PyTorch)
    JavaScipt设计模式初探-代理模式(二) 保护代理
  • 原文地址:https://blog.csdn.net/syzdev/article/details/126157124