希沃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
來實現三維渲染引擎. 它能夠解析並渲染市面上經常使用的三維模型數據格式, 具備邊框渲染/片元渲染/貼圖功能/光照陰影等.數組
這是一個系列的, 因此指望讀者能夠按部就班閱讀. 這裏附上上期連接:
上回咱們的demo進展到了實現一個線框渲染器, 它能夠將咱們特定的模型描述(由8個點, 12個面組成的立方體)的線框的投影圖像渲染在canvas上. 而且支持旋轉運動和不一樣的投影透視效果. 直觀來看就是這樣:
線框的表現力確實不好, 咱們甚至沒法分辨哪些點在前哪些點在後, 沒法表現點和麪之間的遮擋關係! 所以這一節的主題即是實現片元渲染, 讓咱們的渲染器demo
的表現力上一臺階。
"片元"是一個專有名詞, 大體是指已經轉化爲窗口座標的頂點所連結成的最小圖形單元. "片元着色器"也是一個專有名詞, 可是着色器不只處理了片元的顏色填充/片元之間的遮擋關係, 還包括了顏色和紋理的插值處理甚至光照的效果, 這裏我們本節所要實現的其實只是"片元着色器"的一部分功能, 其餘功能咱們先按下不表. 像我們demo1.3
中所示的立方體來講, 每一個立方體由兩個三角形的面所組成, 一共有12個面, 所以每個三角形即是一個片元.
聽起來也不難嘛, 不就是原來只渲染邊框, 如今把邊框和麪的填充色一塊兒渲染了. 查看了下MDN的文檔(developer.mozilla.org/zh-CN/docs/…), canvas正好有這樣的API, 只要在beginPath
和closePath
之間用lineto
連成一個封閉圖形, 再執行fill
便可完成填色, 是否真就如此簡單呢? 說幹就幹吧.
爲了達到需求, 咱們將 1.3/src/render/canvas.js
的代碼做以下修改, 讓它在繪製線框的同時也繪製填充色! 這裏讀者能夠打開1.3文件夾裏的代碼作以下修改並查看效果:
drawline
裏面的beginPath
和closePath
提取到drawline
外部, 這樣方便整個三角造成爲一個總體來填色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()
/** * 改動結束 */
})
}
}
複製代碼
若是你改對了便會發現, 的確又有藍色線框, 又有綠色表面, 一切都這麼美好!
然而事情沒有想象的這麼簡單, 靜態圖片歲月靜好, 一旦圖形旋轉起來, 立刻就發現事情大條了!
既然可達鴨都發現了, 相信聰明的讀者你也發現了. 一旦圖形旋轉起來, 便會出現奇怪的遮擋現象! (以下圖所示) 一些時間裏, 某些片元會超出預期地遮擋住其餘片元.
那麼這是爲何呢?
一個立方體有12
個片元, 每一個片元之間的渲染都是獨立的, 那麼會出現一個現象, 先繪製出來的片元會被後繪製出來的片元所覆蓋. 而片元繪製的前後順序實際上是咱們人爲定義的毫無心義, 所以會出現上圖詭異的一幕. 所以咱們須要思考真正指望的遮擋需求是什麼? 爲了幫助你們思考, 我來舉一些栗子.
z
值大的顏色擋住z值小的顏色, 咱們採用的是右手座標系(不清楚的可看上一篇), z
軸正方向指向紙外, 所以z數值大的點距離攝像頭較近, 這個應該好理解, 近處的物品遮擋了遠處的物品a
片元只遮擋了b
片元的一部分, 而不是整個片元.abc
三個片元有可能互相循環遮擋, 各露出一部分.假如片元a
和b
二者的三個頂點分別爲a1
, a2
, a3
和b1
, b2
, b3
, 若是a
都比b
的z
值大, 那麼容易處理, 只須要先繪製片元b再繪製片元a
, 那麼a
就會把b
給遮擋住.
這很天然, 假如咱們先繪製遠處的片元再繪製近處的, 這樣即可以利用繪製的前後順序來實現遮擋關係了. 然而事情並不簡單, 咱們不只要知足需求1還要知足需求2和3. 需求3有點拗口, 這裏我用三張紙片擺了個樣子幫你們理解, 以下圖.
需求3
實際上是需求2
的一種特殊狀況, 只是爲了幫助你們更直觀地理解. 你說這種狀況下ab
c三個片元先繪製那個好呢? 不管先繪製哪個, 都沒法知足咱們的需求. 所以這個繪製順序的方案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
的長度爲width
和height
乘積的4
倍, data
中按順序存儲着從左上到右下每一行像素點的4
個通道的數值, RGBA4
個通道值域從0
到255
, 2
的8
次方便是256
也就是說canvas
的內部是RGBA4
通道8
位深度的.
舉例當width
爲10
, height
爲10
, 則第一行像素咱們命名爲點0
到點9
, 則data
數組的前4
位分別控制着點0
的R
值G
值B
值A
值, 前40
位爲[R0, G0, B0, A0, .... , R9, G9, B9, A9]
.
注意看, 這個方塊會變顏色的. 相信從這個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
從而使得模型的圖像重回到畫布上.
爲了達到上述目的咱們重寫了drawPoint
和drawLine
的實現, 在裏面修改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
相差無幾, 可是因爲咱們的需求更復雜了, 所以使用更靈活的方式來進行渲染, 實現方式大不同. 這個過程當中, 筆者但願不是直接拋出最終的解決方案, 而是根據每個階段的目標需求, 採用最短的路徑來實現, 最終需求升級纔會採用更復雜的方案來知足更復雜的需求, 在這個過程當中與讀者一塊兒探索並完成目標, 畢竟這更貼近於咱們平常開發過程.
僅僅繪製線框仍是不能體現出採用緩存區繪製方案的優點, 接下來我們又要開始繪製片元啦! 片元呢, 在咱們這裏的定義它是個三角形, 如今要作的就是找出三角形內部的全部點, 並調用drawPoint
將它們都染上色.
這裏要找到全部內部點的掃描方式有不少種, 讀者也能夠進行不一樣的嘗試. 舉個栗子, 方法一能夠將三角形沿着y
軸數值切割爲多個高1px
的水平長條, 方法二也能夠在BC
邊上取點D
並鏈接AD
線段, 隨着D
在AB
上的滑動, 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
. vUp
和vDown
連線被通過vMid
的水平線切割的點, 稱爲vMag
. 所以三角形被分割成兩個, 分別是vUp, vMid, vMag
, 和 vDown, vMid, vMag
. 咱們稱爲上三角和下三角. 各自用水平線掃描並drawLine
, 最終完成顏色填充.
這裏咱們能夠看到相比於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
查看代碼和運行效果)
至此咱們完成了片元着色器的簡單實現, 這裏其實理想化了不少細節, 例如整個片元的顏色都是統一的, 現實狀況裏主要是用貼圖進行填充, 這種狀況下便須要取色和顏色插值處理. 所以這還不是一個完整的着色器, 這些咱們將在這個系列裏接下來的章節去一塊兒探索和學習, 盡情期待吧.
總結一下本節, 本節咱們基於以前的線框渲染器demo嘗試進行片元的顏色填充, 在這個過程當中簡單的嘗試發現了遮擋關係沒法正確表達, 思考問題的本質以後找到了操縱像素的API, 並利用緩衝區和深度緩衝進行遮擋關係的處理, 最終完成了片元的簡單渲染.
既然看到這裏了, 何不起身打開電腦對着這個github倉庫一陣克隆, 將紙上得來變成躬身練習, 相信會有更好的學習效果.
github倉庫地址:github.com/ShaojieLiu/…
因爲篇幅的限制, 本節接近尾聲了. 其實比我預想中進展的要慢, 接下來還有不少東西要講, 3D
文件數據格式解析/貼圖/光照 等等. 經過上一篇與讀者在評論區互動發現以前不少東西講得不夠細緻和透徹, 所以此次放慢了節奏, 包括操縱元素也能夠花了一個demo
來進行講解演示. 指望能對你們有所幫助.
本文是否對你有幫助呢? 不管你是早就知道, 仍是一看就透, 亦或是雲裏霧裏仍是先馬後看, 歡迎點贊收藏關注, 感謝各位父老鄉親. 有不嚴謹之處歡迎討論指正, 感謝.