用JS从零开始搭建3D渲染引擎(二)

希沃ENOW大前端javascript

公司官网:CVTE(广州视源股份)前端

团队:CVTE旗下将来教育希沃软件平台中心enow团队java

本文做者:git

背景及目的

为何要写这个系列呢? 这个问题在本系列的第一篇文章中回答了, 你们能够向上翻看.程序员

这系列文章以代码Demo为线索, 从这个demo的搭建过程当中去深度理解三维渲染的要素和环节. 具备如下特色:github

一. 不使用webgl技术来完成三维渲染, webgl规范帮咱们封装了不少底层实现, 所以也屏蔽了一部分重要的细节, 笔者更但愿webgl技术只是提供了对接GPU计算的接口, 让咱们可使用GPU的力量来提高计算效率, 固然不使用GPU仅使用CPU也能作到一样的事, 只不过效率低一些, 可是以学习为目的的话足够了, 反而会使咱们更清晰, 所以咱们会使用纯粹JS代码来进行全部的运算和绘制而且最终实现一个"3D渲染引擎";web

二. 咱们利用demo的搭建过程来理解三维渲染, 所以在这个过程当中咱们会分为几个小阶段, 每个阶段有阶段性目标做为驱动, 有时会用比较简易的方法来达到目的, 当阶段性目标变得更复杂, 可能会推掉以前的部分实现来知足更复杂的需求;算法

三. 既然是以Demo为线索和主体, 全部的代码都是可得的, 在这个仓库里(github.com/ShaojieLiu/…), 指望你们能够去下载并运行, 甚至亲自从零开始一块儿搭建, 相信能有所收获!canvas

最终咱们会基于2D渲染的API来实现三维渲染引擎. 它能够解析并渲染市面上经常使用的三维模型数据格式, 具备边框渲染/片元渲染/贴图功能/光照阴影等.数组

话接上回

这是一个系列的, 因此指望读者能够按部就班阅读. 这里附上上期连接:

用JS从零开始搭建3D渲染引擎(一)

上回咱们的demo进展到了实现一个线框渲染器, 它能够将咱们特定的模型描述(由8个点, 12个面组成的立方体)的线框的投影图像渲染在canvas上. 而且支持旋转运动和不一样的投影透视效果. 直观来看就是这样:

Screen Recording 2020-10-26 at 12.49.57 AM.gif

线框的表现力确实不好, 咱们甚至没法分辨哪些点在前哪些点在后, 没法表现点和面之间的遮挡关系! 所以这一节的主题即是实现片元渲染, 让咱们的渲染器demo的表现力上一台阶。

片元着色器

"片元"是一个专有名词, 大体是指已经转化为窗口坐标的顶点所连结成的最小图形单元. "片元着色器"也是一个专有名词, 可是着色器不只处理了片元的颜色填充/片元之间的遮挡关系, 还包括了颜色和纹理的插值处理甚至光照的效果, 这里我们本节所要实现的其实只是"片元着色器"的一部分功能, 其余功能咱们先按下不表. 像我们demo1.3中所示的立方体来讲, 每一个立方体由两个三角形的面所组成, 一共有12个面, 所以每个三角形即是一个片元.

简单尝试

听起来也不难嘛, 不就是原来只渲染边框, 如今把边框和面的填充色一块儿渲染了. 查看了下MDN的文档(developer.mozilla.org/zh-CN/docs/…), canvas正好有这样的API, 只要在beginPathclosePath之间用lineto连成一个封闭图形, 再执行fill便可完成填色, 是否真就如此简单呢? 说干就干吧.

为了达到需求, 咱们将 1.3/src/render/canvas.js 的代码做以下修改, 让它在绘制线框的同时也绘制填充色! 这里读者能够打开1.3文件夹里的代码作以下修改并查看效果:

  1. drawline里面的beginPathclosePath提取到drawline外部, 这样方便整个三角造成为一个总体来填色
  2. 设置fillstyle, 并在stroke()调用以后执行fill()调用, 填充颜色

具体如何改动我已经标注在下面代码块里了, 若是还不清晰的话, 能够打开2.1文件夹来查看.

class Canvas extends GObject {
  // 无改动的方法先忽略

  drawline(v1, v2, color) {
    /** * 改动开始 */
    // console.log('drawline', v1, v2)
    const ctx = this.ctx
    // ctx.beginPath()
    ctx.strokeStyle = color.toRgba()
    // ctx.moveTo(v1.x, v1.y)
    ctx.lineTo(v2.x, v2.y)
    // ctx.closePath()
    // ctx.stroke()
    /** * 改动结束 */
  }

  drawMesh(mesh, cameraIndex) {
    const { indices, vertices } = mesh
    const { w, h } = this

    let { position, target, up } = Camera.new(cameraIndex || 0)
    const view = Matrix.lookAtLH(position, target, up)
    const projection = Matrix.perspectiveFovLH(8, w / h, 0.1, 1)

    const rotation = Matrix.rotation(mesh.rotation)
    const translation = Matrix.translation(mesh.position)
    const world = rotation.multiply(translation)
    const transform = world.multiply(view).multiply(projection)

    // console.log('transform', transform, world, rotation, translation)
    const ctx = this.ctx

    const color = Color.blue()
    indices.forEach(ind => {
      const [v1, v2, v3] = ind.map(i => {
        return this.project(vertices[i], transform).position
      })
      /** * 改动开始 */
      ctx.beginPath()
      ctx.moveTo(v1.x, v1.y)
      this.drawline(v1, v2, color)
      this.drawline(v2, v3, color)
      this.drawline(v3, v1, color)
      ctx.fillStyle = Color.green().toRgba()
      ctx.closePath()
      ctx.fill()
      ctx.stroke()
      /** * 改动结束 */
    })
  }
}

复制代码

若是你改对了便会发现, 的确又有蓝色线框, 又有绿色表面, 一切都这么美好!

然而事情没有想象的这么简单, 静态图片岁月静好, 一旦图形旋转起来, 立刻就发现事情大条了!

既然可达鸭都发现了, 相信聪明的读者你也发现了. 一旦图形旋转起来, 便会出现奇怪的遮挡现象! (以下图所示) 一些时间里, 某些片元会超出预期地遮挡住其余片元.

那么这是为何呢?

Screen-Recording-2021-02-18-at-4.40.12-PM.gif

片元遮挡

一个立方体有12个片元, 每一个片元之间的渲染都是独立的, 那么会出现一个现象, 先绘制出来的片元会被后绘制出来的片元所覆盖. 而片元绘制的前后顺序实际上是咱们人为定义的毫无心义, 所以会出现上图诡异的一幕. 所以咱们须要思考真正指望的遮挡需求是什么? 为了帮助你们思考, 我来举一些栗子.

  1. z值大的颜色挡住z值小的颜色, 咱们采用的是右手坐标系(不清楚的可看上一篇), z轴正方向指向纸外, 所以z数值大的点距离摄像头较近, 这个应该好理解, 近处的物品遮挡了远处的物品
  2. 有可能a片元只遮挡了b片元的一部分, 而不是整个片元.
  3. abc三个片元有可能互相循环遮挡, 各露出一部分.

假如片元ab二者的三个顶点分别为a1, a2, a3b1, b2, b3, 若是a都比bz值大, 那么容易处理, 只须要先绘制片元b再绘制片元a, 那么a就会把b给遮挡住.

这很天然, 假如咱们先绘制远处的片元再绘制近处的, 这样即可以利用绘制的前后顺序来实现遮挡关系了. 然而事情并不简单, 咱们不只要知足需求1还要知足需求2和3. 需求3有点拗口, 这里我用三张纸片摆了个样子帮你们理解, 以下图.

需求3实际上是需求2的一种特殊状况, 只是为了帮助你们更直观地理解. 你说这种状况下abc三个片元先绘制那个好呢? 不管先绘制哪个, 都没法知足咱们的需求. 所以这个绘制顺序的方案GG. 这个方案GG的本质是什么呢?

我认为绘制顺序的方案不管如何也没法知足需求的关键在于, 遮挡关系的最小单位不是片元而是像素点. 所以不管程序员如何调整代码, 只要他将绘制片元做为一个最小的单元, 那么此题无解. 因此, 咱们要寻找的是操纵像素的API而不是绘制图形的API(绘制图形的API都是以图形做为最小单位的).

操纵像素

继续翻看MDN文档, canvas也提供了这样底层的API来进行像素级别的操做(developer.mozilla.org/zh-CN/docs/…).

其中最重要的入参imageData的数据格式如文档所示(developer.mozilla.org/zh-CN/docs/…). 另外还能够经过getImageData接口来得到imageData. 这个新的API比较底层比较抽象, 不太经常使用, 因此咱们先来练习一下使用它.

那既然这个API这么强大, 那我们练习的小目标即是使用这个API表达出 256*256*256 种颜色的渐变过程吧. RGB分别有3个自由度, 平面XY坐标能够覆盖其中两个自由度, 还有一个自由度就用时间来覆盖吧. 读者能够想一想如何实现.

const main = () => {
  const c = document.querySelector('#canvas')
  const ctx = c.getContext('2d')
  const w = c.width
  const h = c.height
  let d = new Uint8ClampedArray(w * h * 4)

  const getData = t => {
    for (let i = 0; i < h; i++) {
      for (let j = 0; j < w; j++) {
        d[i * 4 * w + j * 4 + 0] = 255 / w * j
        d[i * 4 * w + j * 4 + 1] = 255 / h * i
        d[i * 4 * w + j * 4 + 2] = Math.abs(t - 255)
        d[i * 4 * w + j * 4 + 3] = 255
      }
    }
    const data = new ImageData(d, w, h)
    return data
  }

  let time = 0
  setInterval(() => {
    ctx.putImageData(getData(time), 0, 0)
    time = (time + 1) % 512
  }, 10)
}

main()
复制代码

寥寥二十几行代码一个美丽的彩色方块便赫然出现, 或许这正是程序的美妙之处吧! 具体代码能够参看demo2.2.

不过因为这个图片上每一个点的颜色都不一致, 传统的压缩方式会大大下降它的质量, 因此看起来远没有程序运行的好看. (惋惜了)

这里借着这个demo讲一下imageData的数据格式, ImageData的构造入参有三个, data, width, height. 其中data的长度为widthheight乘积的4倍, data中按顺序存储着从左上到右下每一行像素点的4个通道的数值, RGBA4个通道值域从0255, 28次方便是256也就是说canvas的内部是RGBA4通道8位深度的.

举例当width10, height10, 则第一行像素咱们命名为点0到点9, 则data数组的前4位分别控制着点0RGBA值, 前40位为[R0, G0, B0, A0, .... , R9, G9, B9, A9].

n_Recording_2021-02-18_at_6.08.54_PM.gif

注意看, 这个方块会变颜色的. 相信从这个demo你能够感觉到这个API的强大之处, 试想这样的绘制需求, 若是用以前的drawline的API即便可以实现, 性能也必将大打折扣. 看着这个图不由想起当年青奥会的吉祥物, 五彩腰子....

深度缓冲

既然咱们如今拥有了操纵画布上每个像素的RGBA值能力, 再回到咱们的需求上面来. 咱们在按顺序绘制片元的时候是能够知道片元覆盖了哪些像素点, 也知道这些像素点的颜色值, 除此以外, 咱们还须要获得这些像素点的Z值, 以便在以后绘制其余片元时若是像素点发生冲突(两个片元的绘制都须要对同一个像素点涂上颜色)能够轻易地判断两者的遮挡关系从而决定保留一方或者以某种算法混色(好比说近处的片元颜色是半透明时).

也便是说咱们不能绘制一个片元便立刻把它的颜色涂在画布上, 由于说不定以后绘制其余近处片元时这个颜色应该被覆盖, 所以咱们须要一个暂存区不只储存全部点的颜色值和存储该颜色点的Z值, 方便做深度比较. 这种暂存区域咱们能够称之为片元绘制缓冲区, 当全部片元绘制结束时该区域能够被应用到画布上, 并清空该变量. 值得一提的是, 通常的三维渲染引擎为了效率和空间, 深度值也是有位数和精度限制的, 当精度不足时, 两个物体深度接近时便会产生深度冲突, 表现就是某些表面若隐若现地闪烁/穿模.

用缓冲区来绘制

咱们的实现思路是, 首先不在片元绘制时填充画布, 而是先初始化dataBuffer变量和depthBuffer变量, 将点的色值推入, 并将该点的Z值推入depthBuffer变量, 以后推入颜色以前先对比Z值, 将Z值大的一方推入dataBuffer, 以此类推以确保buffer中的像素点都是Z值最大的留存下来. 直到全部片元颜色推入完毕, 则将dataBuffer应用到canvas上.

这里的改动比较大, 咱们须要将以前绘制线与面的实现都更改才能知足该需求. 大体以下:

class Canvas extends GObject {
  constructor(canvas) {
    super(canvas)
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.w = canvas.width
    this.h = canvas.height
    // 初始化加多如下两行
    this.dataBuffer = new Uint8ClampedArray(this.w * this.h * 4)
    this.depthBuffer = new Array(this.w * this.h)
  }

 	// 不变的方法先忽略

  drawline(v1, v2, color) {
    // 这里全要改, 怎么改以后再说
  }

  drawMesh(mesh, cameraIndex) {
    // 矩阵运算这些不变, 先无论, 省略

    indices.forEach(ind => {
      // 这里全要改, 怎么改以后再说
    })
    
    // 加多这一句
    ctx.putImageData(new ImageData(this.dataBuffer, this.w, this.h), 0, 0)
  }
}
复制代码

咱们先加上dataBuffer的初始化, 而且在绘制的最后将dataBuffer应用到画布, 如此一来你会发现canvas变成空白了, 由于putImageData里是一个空的数据, 接下来咱们便须要在drawTriangle/drawLine的实现里去改变this.dataBuffer从而使得模型的图像重回到画布上.

从新实现绘制点线面

为了达到上述目的咱们重写了drawPointdrawLine的实现, 在里面修改dataBuffer, 而且在drawMesh开始时initBuffer, 最后putImageData实现绘制.

class Canvas extends GObject {
  constructor(canvas) {
    super(canvas)
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.w = canvas.width
    this.h = canvas.height
    // 初始化加多如下两行
    this.initBuffer()
  }

  initBuffer() {
    this.dataBuffer = new Uint8ClampedArray(this.w * this.h * 4)
    this.depthBuffer = Array.from({ length: this.w * this.h }).map(() => -255535)
  }

  drawPoint(v, color) {
    const x = Math.round(v.x)
    const y = Math.round(v.y)
    const index = x + y * this.w

    if (v.z > this.depthBuffer[index]) {
      this.depthBuffer[index] = v.z
      this.dataBuffer[index * 4 + 0] = color.r
      this.dataBuffer[index * 4 + 1] = color.g
      this.dataBuffer[index * 4 + 2] = color.b
      this.dataBuffer[index * 4 + 3] = color.a
    }
  }

  drawLine(v1, v2, color) {
    const delta = v1.sub(v2)
    const deltaX = Math.abs(delta.x)
    const deltaY = Math.abs(delta.y)
    const len = deltaX > deltaY ? deltaX : deltaY

    for (let i = 0; i < len; i++) {
      const p = v1.interpolate(v2, i / len)
      this.drawPoint(p, color)
    }
  }

  drawMesh(mesh, cameraIndex) {
    this.initBuffer()

    const { indices, vertices } = mesh
    const transform = this.getTransform(mesh, cameraIndex)
    const ctx = this.ctx
    const color = Color.blue()

    indices.forEach((ind, i) => {
      const [v1, v2, v3] = ind.map(i => {
        return this.project(vertices[i], transform).position
      })

      this.drawLine(v1, v2, color)
      this.drawLine(v2, v3, color)
      this.drawLine(v3, v1, color)
    })

    ctx.putImageData(new ImageData(this.dataBuffer, this.w, this.h), 0, 0)
  }
}

复制代码

这里代码改动比较多, 读者能够打开demo2.3来查看代码和运行效果. 这里能够看到运行效果几乎和demo1.3相差无几, 可是因为咱们的需求更复杂了, 所以使用更灵活的方式来进行渲染, 实现方式大不同. 这个过程当中, 笔者但愿不是直接抛出最终的解决方案, 而是根据每个阶段的目标需求, 采用最短的路径来实现, 最终需求升级才会采用更复杂的方案来知足更复杂的需求, 在这个过程当中与读者一块儿探索并完成目标, 毕竟这更贴近于咱们平常开发过程.

Screen Recording 2020-10-26 at 12.49.57 AM (1).gif

绘制片元

仅仅绘制线框仍是不能体现出采用缓存区绘制方案的优点, 接下来我们又要开始绘制片元啦! 片元呢, 在咱们这里的定义它是个三角形, 如今要作的就是找出三角形内部的全部点, 并调用drawPoint将它们都染上色.

这里要找到全部内部点的扫描方式有不少种, 读者也能够进行不一样的尝试. 举个栗子, 方法一能够将三角形沿着y轴数值切割为多个高1px的水平长条, 方法二也能够在BC边上取点D并链接AD线段, 随着DAB上的滑动, AD便会通过内部全部点完成扫描. 还有哪些扫描切割的方法效率更高的你们能够在评论区讨论哦.

这里咱们便采用第一种方法"水平长条切割法".

drawTriangle(v1, v2, v3, color) {
    // 三个顶点根据Y值进行排序
    const [vUp, vMid, vDown] = [v1, v2, v3].sort((a, b) => a.y - b.y)
    // vUp和vDown连线被通过vMid的水平线切割的点, 称为vMag
    const vMag = vUp.interpolate(vDown, (vMid.y - vUp.y) / (vDown.y - vUp.y))

    for (let y = vUp.y; y < vDown.y; y++) {
      if (y < vMid.y) {
        // 三角形的上半部分
        const vUpMid = vUp.interpolate(vMid, (y - vUp.y) / (vMid.y - vUp.y))
        const vUpMag = vUp.interpolate(vMag, (y - vUp.y) / (vMag.y - vUp.y))
        this.drawLine(vUpMid, vUpMag, color)
      } else {
        // 三角形的下半部分
        const vDownMid = vDown.interpolate(vMid, (y - vDown.y) / (vMid.y - vDown.y))
        const vDownMag = vDown.interpolate(vMag, (y - vDown.y) / (vMag.y - vDown.y))
        this.drawLine(vDownMid, vDownMag, color)
      }
    }
  }
复制代码

这里的逻辑不复杂, 可是须要一点几何知识, 难以理解的话最好画一个图出来方便理解. 这里三个顶点根据Y值进行排序, 依次为vUp, vMid, vDown. vUpvDown连线被通过vMid的水平线切割的点, 称为vMag. 所以三角形被分割成两个, 分别是vUp, vMid, vMag, 和 vDown, vMid, vMag. 咱们称为上三角和下三角. 各自用水平线扫描并drawLine, 最终完成颜色填充. n_Recording_2021-02-19_at_5.06.39_PM.gif

深度冲突

这里咱们能够看到相比于demo2.1, 这里的片元遮挡关系已是正确的了. 细心地同窗会注意到这里有个使人很不舒服的现象, 边框线若隐若现不停闪烁. 为何会这样呢?

由于在现实中是不存在边框线的, 并且咱们绘制边框线的方式其实是把顶点相连, 这样边框线便会和片元的边缘彻底重合, 那彻底重合时应该呈现谁的颜色呢? 这就取决于计算的精度, 有的点边框在前, 有的点片元在前, 所以边框线变成了虚线, 一旦旋转起来就会一闪一闪的了.

这里咱们的需求实际上是指望一同绘制的元素(片元或者边框线)若是z数值相差不大的状况下要么彻底被遮挡要么彻底不被遮挡, 不但愿闪烁或者续断. 所以我作了简单的处理, 在判断z值时提供一个阈值, 使得先绘制的元素不容易被遮挡, 固然这不是最完美的解法, 你们也能够在评论区讨论一下如何更好解决.

drawPoint(v, color) {
    const x = Math.round(v.x)
    const y = Math.round(v.y)
    const index = x + y * this.w

    // 这里的魔数即是解决深度冲突的方式之一
    if (v.z > this.depthBuffer[index] + 0.0005) {
      this.depthBuffer[index] = v.z
      this.dataBuffer[index * 4 + 0] = color.r
      this.dataBuffer[index * 4 + 1] = color.g
      this.dataBuffer[index * 4 + 2] = color.b
      this.dataBuffer[index * 4 + 3] = color.a
    }
  }
复制代码

最后, 展现一下解决完深度冲突以后的demo效果吧. (能够在github仓库2.4demo查看代码和运行效果)

n_Recording_2021-02-19_at_5.16.09_PM.gif

至此咱们完成了片元着色器的简单实现, 这里其实理想化了不少细节, 例如整个片元的颜色都是统一的, 现实状况里主要是用贴图进行填充, 这种状况下便须要取色和颜色插值处理. 所以这还不是一个完整的着色器, 这些咱们将在这个系列里接下来的章节去一块儿探索和学习, 尽情期待吧.

小结

总结一下本节, 本节咱们基于以前的线框渲染器demo尝试进行片元的颜色填充, 在这个过程当中简单的尝试发现了遮挡关系没法正确表达, 思考问题的本质以后找到了操纵像素的API, 并利用缓冲区和深度缓冲进行遮挡关系的处理, 最终完成了片元的简单渲染.

image.png

既然看到这里了, 何不起身打开电脑对着这个github仓库一阵克隆, 将纸上得来变成躬身练习, 相信会有更好的学习效果.

github仓库地址:github.com/ShaojieLiu/…

因为篇幅的限制, 本节接近尾声了. 其实比我预想中进展的要慢, 接下来还有不少东西要讲, 3D文件数据格式解析/贴图/光照 等等. 经过上一篇与读者在评论区互动发现以前不少东西讲得不够细致和透彻, 所以此次放慢了节奏, 包括操纵元素也能够花了一个demo来进行讲解演示. 指望能对你们有所帮助.

本文是否对你有帮助呢? 不管你是早就知道, 仍是一看就透, 亦或是云里雾里仍是先马后看, 欢迎点赞收藏关注, 感谢各位父老乡亲. 有不严谨之处欢迎讨论指正, 感谢.

相关文章
相关标签/搜索