碼農(nóng)干貨系列【10】--光線追蹤進(jìn)階:javascript玩轉(zhuǎn)3D紋理映射
2013-03-18 07:59 【當(dāng)耐特】 閱讀(7394) 評(píng)論(11) 收藏 舉報(bào)簡(jiǎn)介
本文在光線追蹤的基礎(chǔ)之上,為了追求渲染速度和效率,去除了光線的反射、去除了透視投影(如我前面兩篇干貨8和干貨9,所以渲染雖然是3D場(chǎng)景,其實(shí)不是真實(shí)看到的,但不影響實(shí)驗(yàn)),進(jìn)行了一些有趣的嘗試。此文將分享這兩天嘗試的成果:3D雕刻。
3D雕刻,顧名思義--在3D物體上進(jìn)行雕刻,所以要達(dá)到的目的不僅僅是渲染幾種常見的幾何形狀,還包括在幾何形狀上繪制、繪畫等等。本文依舊使用大家熟悉的javascript語言,HTML5 canvas作為顯示屏。
在讀本文之前,最好可以了解一些下面這些基礎(chǔ)知識(shí):
正交投影
線性代數(shù)基礎(chǔ)
數(shù)據(jù)結(jié)構(gòu)和算法
javascript基礎(chǔ)知識(shí)
射線、AABB、面、球體之間碰撞檢測(cè)算法
透視投影(本文雖然略去了canvas和影像屏的mapping,使用了固定視錐體去渲染,所以下面的的demo不建議去修改eye的坐標(biāo))
Vector3的幾何意義(使用時(shí)候要區(qū)分什么時(shí)候代表點(diǎn),什么時(shí)候代表向量)
Canvas像素操作getImageData/putImageData/跨域、漸變createLinearGradient、繪制文字fillText、圖片drawImage/base64等
如果不了解上面相關(guān)的內(nèi)容,可以做一些search,或者通過本文做一些熟悉。
Vector3類
這個(gè)類是最常用的類了。最重要的一點(diǎn)就使用的時(shí)候理解它是代表點(diǎn)還是向量,以及各個(gè)方法的幾何意義。
var Vector3 = function (x, y, z) { this.x = x; this.y = y; this.z = z; }; Vector3.prototype = { dot: function (v) { return this.x * v.x + this.y * v.y + this.z * v.z; }, sub: function (v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); }, normalize: function () { return this.divideScalar(this.length()); }, divideScalar: function (s) { return new Vector3(this.x / s, this.y / s, this.z / s); }, length: function () { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); }, sqrLength: function () { return this.x * this.x + this.y * this.y + this.z * this.z; }, multiplyScalar: function (s) { return new Vector3(this.x * s, this.y * s, this.z * s); }, add: function (v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); }, cross: function (v) { return new Vector3(-this.z * v.y + this.y * v.z, this.z * v.x - this.x * v.z, -this.y * v.x + this.x * v.y); }, round: function () { return new Vector3(Math.round(this.x), Math.round(this.y), Math.round(this.z)) }, distanceTo: function (v) { return Math.sqrt(this.distanceToSquared(v)); }, distanceToSquared: function (v) { var dx = this.x - v.x; var dy = this.y - v.y; var dz = this.z - v.z; return dx * dx + dy * dy + dz * dz; } }
題外話:為什么Vector3?為什么不用齊次坐標(biāo)Vector4?(主要是因?yàn)闆]有透視投影的過程了。)
var Vector4 = function ( x, y, z, w ) { this.x = x || 0; this.y = y || 0; this.z = z || 0; this.w = w || 1; }
Vector4的最后一個(gè)參數(shù)w是干什么的?為什么不使用4*4矩陣?為什么是4*4?不是3*3?
“齊次坐標(biāo)表示是計(jì)算機(jī)圖形學(xué)的重要手段之一,它既能夠用來明確區(qū)分向量和點(diǎn),同時(shí)也更易用于進(jìn)行仿射(線性)幾何變換。”
—— F.S. Hill, JR
有了w,可以進(jìn)行透視除法,隱藏面消除等算法。那怎么才能知道w?
把3*3的矩陣擴(kuò)大成4*3的矩陣==>增加的那個(gè)維度可以用來進(jìn)行w的計(jì)算;
線性代數(shù)的基礎(chǔ)是過原點(diǎn),向量是標(biāo)量的數(shù)組,矩陣是向量的數(shù)組,把4*3的矩陣擴(kuò)大成4*4的矩陣==>增加的那個(gè)維度可以用來表示平移。
所以:利用齊次坐標(biāo)技術(shù)來描述空間各點(diǎn)的坐標(biāo),用4*4的矩陣來解決空間各點(diǎn)的變換,已經(jīng)成了計(jì)算機(jī)圖形學(xué)的一個(gè)標(biāo)準(zhǔn)。
-----摘自《HTML5實(shí)驗(yàn)室:Canvas世界》
射線與正方體碰撞檢測(cè)
為了簡(jiǎn)單起見,本文出現(xiàn)的cube都屬于AABB,不屬于OBB。為了渲染正方體,首先需要推導(dǎo)出射線與正方體是否相交和交點(diǎn)坐標(biāo)。
使用矩形的中心點(diǎn)和邊長(zhǎng)表示這個(gè)矩形:
var Cube = function (center, length) { this.center = center; this.length = length; this.hLength = length / 2; this.minX = this.center.x - this.hLength; this.maxX = this.center.x + this.hLength; this.minY = this.center.y - this.hLength; this.maxY = this.center.y + this.hLength; this.minZ = this.center.z - this.hLength; this.maxZ = this.center.z + this.hLength; }
使用射線的起點(diǎn)和方向表示射線:
Ray3 = function (origin, direction) { this.origin = origin; this.direction = direction; } Ray3.prototype = { getPoint: function (t) { return this.origin.add(this.direction.multiplyScalar(t)); } }
給定射線和正方體之后,分6次求出射線也正方體六個(gè)面(無區(qū)域限制)的交點(diǎn),如果有交點(diǎn),再判斷該交點(diǎn)是否在正方體矩形面之內(nèi)。都滿足的話,判定為相交。如下面代碼所示:
Cube.prototype.intersect = function (r3) { var d = r3.direction, p1 = r3.origin; var irs = []; var ir1 = this.getTIntersectPlane(p1, d, "z", this.center.z - this.hLength); var ir2 = this.getTIntersectPlane(p1, d, "z", this.center.z + this.hLength); var ir3 = this.getTIntersectPlane(p1, d, "x", this.center.x - this.hLength); var ir4 = this.getTIntersectPlane(p1, d, "x", this.center.x + this.hLength); var ir5 = this.getTIntersectPlane(p1, d, "y", this.center.y - this.hLength); var ir6 = this.getTIntersectPlane(p1, d, "y", this.center.y + this.hLength); if (ir1) irs.push(ir1); if (ir2) irs.push(ir2); if (ir3) irs.push(ir3); if (ir4) irs.push(ir4); if (ir5) irs.push(ir5); if (ir6) irs.push(ir6); if (irs.length === 1) { return irs[0].cp; } else if (irs.length === 2) { if (irs[0].t > irs[1].t) return irs[1].cp; if (irs[1].t > irs[0].t) return irs[0].cp; } return null; } Cube.prototype.getTIntersectPlane = function (p1,d,type,value) { var _intersectResult = []; var t, cp; if (type === "z") { t= (value - p1.z) / d.z; cp = p1.add(d.multiplyScalar(t)); if (cp.x < this.maxX && cp.x > this.minX && cp.y < this.maxY && cp.y > this.minY) return { t: t, cp: cp }; } if (type === "x") { t = (value - p1.x) / d.x; cp = p1.add(d.multiplyScalar(t)); if (cp.z < this.maxZ && cp.z > this.minZ && cp.y < this.maxY && cp.y > this.minY) return { t: t, cp: cp }; } if (type === "y") { t = (value - p1.y) / d.y; cp = p1.add(d.multiplyScalar(t)); if (cp.x < this.maxX && cp.x > this.minX && cp.z < this.maxZ && cp.z > this.minZ) return { t: t, cp: cp }; } return null; }
可以想象,直線與正方體的交點(diǎn)只可能是兩個(gè)或者一個(gè)。所以當(dāng)交點(diǎn)為兩個(gè)的時(shí)候,最后返回離射線發(fā)射點(diǎn)近的相交點(diǎn),該點(diǎn)才是先與正方體相交的點(diǎn)。
if (irs[0].t > irs[1].t) return irs[1].cp; if (irs[1].t > irs[0].t) return irs[0].cp;
渲染測(cè)試(在上篇球的基礎(chǔ)上加入正方體):
修改球的半徑、正方體的邊長(zhǎng)等參數(shù)==>
繪制漸變文字
要在正方體的外表面繪制文字,先嘗試在canvas中繪制漸變文字。canvas提供了createLinearGradient方法來設(shè)置fillStyle為漸變色,然后使用fillText來繪制文字。如下所示:
var linearText = ctx.createLinearGradient(0, 0, 200, 200); linearText.addColorStop(0, "blue"); linearText.addColorStop(0.5, "yellow"); linearText.addColorStop(1, "red"); ctx.fillStyle = linearText; var fontSize = fontSize || "200px"; ctx.font = "bold 200px Arial"; ctx.textBaseline = "top"; ctx.fillText("當(dāng)", 0, 0);
效果如下所示:
修改顯示的文字和字體大小==>
2D文字Mapping立方體表面
因?yàn)橐谡襟w表面繪制文字,所以在把2D文字里面每個(gè)像素的坐標(biāo)和顏色保存起來,然后對(duì)應(yīng)到正方體的表面。所以專門創(chuàng)建了一個(gè)方法createWordData來生成文字像素坐標(biāo)和顏色信息:
function createWordData(word) { var canvas = document.createElement("canvas"); canvas.width = 90; canvas.height = 90; var ctx = canvas.getContext("2d"); var linearText = ctx.createLinearGradient(0, 0, 90, 90); linearText.addColorStop(0, "blue"); linearText.addColorStop(0.5, "yellow"); linearText.addColorStop(1, "red"); ctx.fillStyle = linearText; ctx.font = "bold 70px Arial"; ctx.textBaseline = "top"; ctx.fillText(word, 20, 15); var imgdata = ctx.getImageData(0, 0, canvas.width, canvas.height); var pixels = imgdata.data, tempN = 0; var wordData = []; for (var y = 0; y < canvas.height; y++) { for (var x = 0; x < canvas.width; x++) { if (pixels[tempN + 3] !== 0) { wordData.push({ position: { x: x, y: y }, color: [pixels[tempN], pixels[tempN + 1], pixels[tempN + 2], pixels[tempN + 3]] }) } tempN += 4; } } return wordData; }
這里使用提取方式是:遍歷每個(gè)像素的rgba中的a是否是0,如果不是0,則判定為在字的像素坐標(biāo)范圍。如果寫過canvas庫(kù)的經(jīng)歷的話,這種方式一定不陌生,一些點(diǎn)擊操作精確到像素級(jí)別的時(shí)候,用如下的方式去判定:
p._testHit = function (ctx) { try { var hit = ctx.getImageData(0, 0, 1, 1).data[3] > 1; } catch (e) { throw "An error has occurred. This is most likely due to security restrictions on reading canvas pixel data with local or cross-domain images."; } return hit; }
題外話:在寫canvas庫(kù)的時(shí)候發(fā)現(xiàn),谷歌瀏覽器精確到像素非常快,而IE9/10精確像素非常慢,包括win8上的webapp/webgame殼(微軟webapp/webgame套上該殼就是native的)也非常非常慢,每次點(diǎn)擊都掉幀,點(diǎn)得越快,掉得越快。因?yàn)榫_到像素涉及到了一些矩陣變換,所以IE該好好優(yōu)化優(yōu)化了,或者基于webkit二次開發(fā)吧,落后Chrome好多好多了。
創(chuàng)建完三個(gè)文字?jǐn)?shù)據(jù)信息,把三個(gè)字mapping到正方體的三個(gè)面:正面、右側(cè)面和頂部表面。
當(dāng)mapping正面時(shí):wordX===CubeX(相對(duì)于左上角,下面一樣)、wordY===CubeY(相對(duì)于左上角,下面一樣)
當(dāng)mapping右側(cè)面時(shí):wordX===CubeZ、wordY===CubeY
當(dāng)mapping頂部表面時(shí):wordX===CubeX、wordY===CubeZ
所以有:
var color = null; for (var k = 0, l = word1.length; k < l; k++) { var dD = word1[k]; if (Math.round(result2.y) === cube.maxY && Math.round(result2.x - cube.minX) === dD.position.x && Math.round(result2.z - cube.minZ) === dD.position.y) { color = dD.color; break; } } if (!color) { for (var k = 0, l = word2.length; k < l; k++) { var dD = word2[k]; if (Math.round(result2.z) === cube.maxZ && Math.round(result2.x - cube.minX) === dD.position.x && Math.round(cube.maxY - result2.y) === dD.position.y) { color = dD.color; break; } } } if (!color) { for (var k = 0, l = word3.length; k < l; k++) { var dD = word3[k]; if (Math.round(result2.x) === cube.maxX && Math.round(cube.maxY - result2.y) === dD.position.y && Math.round(cube.maxZ - result2.z) === dD.position.x) { color = dD.color; break; } } }
渲染測(cè)試效果如下:
修改各個(gè)參數(shù)試試( 這個(gè)有點(diǎn)久,耐心等會(huì)兒,或者使用chrome瀏覽器)==>
2D圖片Mapping立方體表面
圖片的mapping也是同樣的道理。不同的地方的,由于getImageData會(huì)報(bào)跨域的安全問題,所以會(huì)受到限制。這里把圖片進(jìn)行base64編碼,然后繪制到canvas當(dāng)中去。
效果如下所示:
通過 這個(gè)網(wǎng)站 把64*64的圖標(biāo)轉(zhuǎn)成base64然后粘貼進(jìn)來 (這個(gè)有點(diǎn)久,耐心等會(huì)兒,或者使用chrome瀏覽器)
(要以data:image/png;base64,開頭,別把整個(gè)img粘貼進(jìn)textarea)
ps:這里還有一只base64企鵝http://1.iamzhanglei.sinaapp.com/base64.html
雕刻球體
最后,要做的是在球體表面進(jìn)行2D雕刻。這里涉及到正交投影的問題。如下圖所示:
透視投影:
正交投影:
這里值得注意的地方是,球體雕刻渲染的管線如下步驟:
1.在球體的正面放上一個(gè)垂直地面的正方形平面(可以理解為正方體的正面),
2.假定文字繪制到正方形平面,
3.正方形上的文字通過正交投影至球體表面。
所以,通過所以正面形平面上有像素的點(diǎn),發(fā)射一條平行于地面(y=0)的射線(可以得到,ray3的方向?yàn)椋?,0,-1))打在球體表面,這個(gè)時(shí)候影像平面是球體表面。所以下面的方法用于保存所有射線通過正交投影打在球體表面上的點(diǎn):
function generateWordToBall(word1, word2, word3, ball, cube) { var color = null; var bca = []; for (var k = 0, l = word1.length; k < l; k++) { var dD = word1[k]; var r3 = new Ray3(new Vector3(cube.minX + dD.position.x - 20, cube.maxY - dD.position.y + 30, cube.maxZ), new Vector3(0, 0, -1)); var result3 = ball.intersect(r3); if (result3) bca.push({ cp: result3.round(), cl: dD.color }); } for (var k = 0, l = word2.length; k < l; k++) { var dD = word2[k]; var r3 = new Ray3(new Vector3(cube.minX + dD.position.x - 60, cube.maxY - dD.position.y, cube.maxZ), new Vector3(0, 0, -1)); var result3 = ball.intersect(r3); if (result3) bca.push({ cp: result3.round(), cl: dD.color }); } for (var k = 0, l = word3.length; k < l; k++) { var dD = word3[k]; var r3 = new Ray3(new Vector3(cube.minX + dD.position.x, cube.maxY - dD.position.y - 20, cube.maxZ), new Vector3(0, 0, -1)); var result3 = ball.intersect(r3); if (result3) bca.push({ cp: result3.round(), cl: dD.color }); } return bca; }
拿到這些點(diǎn)之后,剩下的就很簡(jiǎn)單明了。只需把從視點(diǎn)發(fā)出去的射線與球體的交點(diǎn)和bca中的對(duì)比,如果在bca當(dāng)中有,則繪制對(duì)應(yīng)文字像素的顏色,在此不再闡述分析。
在線演示
that's all.Have fun!




浙公網(wǎng)安備 33010602011771號(hào)