Three.js中的webgl_interactive_cube例子展示了picking-拾取物體(使用點(diǎn)擊等方式選中渲染物體)的行為访锻。在這里借助ray casting方式實(shí)現(xiàn)了用戶交互。用戶可以在一大群隨機(jī)立方體上準(zhǔn)確選擇出一個具體的立方體健蕊。
實(shí)現(xiàn)過程
首先渲染出立方體群匠题。一開始使用隨機(jī)數(shù)生成特定范圍的位置边涕,伸縮尺寸,色彩等信息衍锚,隨后使用這些信息渲染出立方體群友题。我們還會加上眼睛圍繞立方體群做圓周運(yùn)動,產(chǎn)生不斷變化的視圖构拳。
立方體群渲染完成后咆爽,我們使用raycaster在鼠標(biāo)點(diǎn)擊處投射出屏幕射線梁棠,如果屏幕射線和被渲染物體相交,則認(rèn)為這個物體被選中斗埂。
這個例子比較復(fù)雜符糊,包含多個子過程:立方體幾何模型數(shù)據(jù)生成,單個立方體渲染呛凶,立方體群渲染男娄,眼睛幀圍繞場景中心做圓周運(yùn)動,用戶拾取機(jī)制等漾稀。
這里模闲,我們模仿webgl_interactive_cube例子,在application端計(jì)算射線與物體相交的方式來決定被選中的立方體崭捍,使用C++和OpenGL ES 3.0獲得了如下的渲染效果尸折,iOS版本實(shí)現(xiàn)源碼可以從github上獲取。
實(shí)現(xiàn)立方體群的渲染
基本立方體的繪制
要實(shí)現(xiàn)立方體的繪制殷蛇,首先需要準(zhǔn)備立方體模型的頂點(diǎn)數(shù)據(jù)实夹。在webgl_interactive_cube例子中并沒有使用硬編碼的頂點(diǎn)數(shù)據(jù),而是實(shí)現(xiàn)了CubeGeometry對象粒梦,可以用于靈活控制生成幾何體頂點(diǎn)數(shù)據(jù)的數(shù)量亮航。CubeGeometry對象生成的幾何頂點(diǎn)數(shù)據(jù)包含頂點(diǎn)和索引。下面為C++實(shí)現(xiàn)的代碼局部:
//構(gòu)建立方體每個面的數(shù)據(jù)
buildPlane(2,1,0, - 1, - 1, depth, height, width, depthSegments, heightSegments, 0 ); // px
buildPlane(2,1,0, 1, - 1, depth, height, - width, depthSegments, heightSegments, 1 ); // nx
buildPlane(0,2,1, 1, 1, width, depth, height, widthSegments, depthSegments, 2 ); // py
buildPlane(0,2,1, 1, - 1, width, depth, - height, widthSegments, depthSegments, 3 ); // ny
buildPlane(0,1,2, 1, - 1, width, height, depth, widthSegments, heightSegments, 4 ); // pz
buildPlane(0,1,2, - 1, - 1, width, height, - depth, widthSegments, heightSegments, 5 ); // nz
//本函數(shù)生成立方體每個面上的頂點(diǎn)后索引數(shù)據(jù)
void CubeGeometry::buildPlane(int idx1,int idx2,int idx3,int udir,int vdir,float width,float height,float depth,int gridX,int gridY,int materialIndex){
float segmentWidth = width / gridX;
float segmentHeight = height / gridY;
float widthHalf = width / 2;
float heightHalf = height / 2;
float depthHalf = depth / 2;
int gridX1 = gridX + 1;
int gridY1 = gridY + 1;
int vertexCounter = 0;
int groupCount = 0;
// generate vertices, normals and uvs
for (int iy = 0; iy < gridY1; iy ++){
float y = iy * segmentHeight - heightHalf;
for (int ix = 0; ix < gridX1; ix ++) {
float x = ix * segmentWidth - widthHalf;
Cvec3 vertice;
// set values to correct vector component
vertice[idx1] = x * udir;
vertice[idx2] = y * vdir;
vertice[idx3] = depthHalf;
// now apply vertice to vertex buffer
vertices.push_back(vertice);
Cvec3 normal;
// set values to correct vector component
normal[idx1] = 0;
normal[idx2] = 0;
normal[idx3] = depth > 0 ? 1 : - 1;
normals.push_back(normal);
// uvs
uvs.push_back(Cvec2(ix/gridX,1-(iy/gridY)));
// counters
vertexCounter += 1;
}
}
// indices
// 1. you need three indices to draw a single face
// 2. a single segment consists of two faces
// 3. so we need to generate six (2*3) indices per segment
for (int iy = 0; iy < gridY; iy ++ ) {
for (int ix = 0; ix < gridX; ix ++ ) {
int a = numberOfVertices + ix + gridX1 * iy;
int b = numberOfVertices + ix + gridX1 * ( iy + 1 );
int c = numberOfVertices + ( ix + 1 ) + gridX1 * ( iy + 1 );
int d = numberOfVertices + ( ix + 1 ) + gridX1 * iy;
// faces
indices.push_back(a);
indices.push_back(b);
indices.push_back(d);
indices.push_back(b);
indices.push_back(c);
indices.push_back(d);
// increase counter
groupCount += 6;
}
}
... ...
}
立方幾何體數(shù)據(jù)生成后匀们,使用phong shader借助GL_TRIANGLES進(jìn)行基本立方體的渲染缴淋。
渲染立方體群
要實(shí)現(xiàn)立方體群的渲染,重點(diǎn)是每個立方體的位置泄朴,方位重抖,以及色彩等屬性的確定,以便于生成的每個基本立方體都可以辨別區(qū)分叼旋。
這些屬性信息的生成代碼如下:
...
int count = 1000;
for (int i = 0; i < count; i ++ ) {
double x = rand()%50 - 25;
double y = rand()%50 - 25;
double z = rand()%50 - 25;
mInstancePos.push_back(Cvec3(x,y,z));
mInstanceOrientation.push_back(Cvec3(rand()%360,rand()%360,rand()%360));
mInstanceScale.push_back(Cvec3((rand()%100)/100.0+0.5,(rand()%100)/100.0+0.5,
(rand()%100)/100.0+0.5));
//random diffuse color
int cInt = 0xffffff * (rand()%100/(100.0));
std::stringstream stream;
stream << std::hex << cInt;
std::string hexString( stream.str() );
Cvec3 diffuseColor = hexStringToRGB(hexString);
mInstanceDiffuseColor.push_back(diffuseColor);
}
...
當(dāng)然還有一個主要問題仇哆,就是大量的幾何體如何實(shí)現(xiàn)有效的渲染,比如怎樣保證幾千個立方體在較高的幀率(FPS)下渲染》蛑玻基本動作包括避免無效的API調(diào)用開銷,避免圖形緩存(vbo,ibo,vao)的無效損耗油讯,避免紋理的過多加載详民。在渲染立方體群時,每個立方體都可以使用相同的vbo/ibo緩存陌兑,使用相同的渲染管線(program)沈跨,借助vao管理vertex array設(shè)置等等。同時兔综,盡量在application端避免不必要的矩陣計(jì)算饿凛,比如每個立方體的位置和方位信息狞玛,不必要每次渲染時都執(zhí)行計(jì)算。
眼睛(相機(jī))視野的變化
當(dāng)立方幾何體群按照隨機(jī)位置涧窒、方位心肪、縮放程度等屬性生成后,保持不變纠吴。我們通過移動眼睛(相機(jī))的方式硬鞍,保持畫面的變換。當(dāng)移動眼睛(眼睛幀)時戴已,我們保持眼睛持續(xù)看向場景的原點(diǎn)固该,同時圍繞球體不斷移動眼睛的位置。所有的立方體在渲染時使用相同的view matrix糖儡。示意代碼如下:
angle+=0.1;
float radius = 10;
float eyeX = radius * sin(angle*M_PI/180);
float eyeY = radius * sin(angle*M_PI/180);
float eyeZ = radius * cos(angle*M_PI/180);
Matrix4 mat_eye = Matrix4::makeLookAtEyeMatrix(Cvec3(eyeX,eyeY,eyeZ), Cvec3(0,0,0), Cvec3(0,1,0));
Matrix4 viewMat = inv(mat_eye);
cubeModel->mat_view=viewMat;
實(shí)現(xiàn)raycast picking(射線投射拾确セ怠)
webgl_interactive_cube例子中采用射線拾取(raycast picking)方式。這種方式通過發(fā)出一條屏幕射線(screen ray)握联,然后檢測屏幕射線是否和被渲染物體(boudning volumes)相交著淆。如果相交,則物體被選中拴疤,反之永部,則未選中。
屏幕射線(Screen Ray)
屏幕射線的定義:用戶點(diǎn)擊屏幕像素()呐矾,發(fā)出一條screen ray苔埋,方向?yàn)椋?,0蜒犯,1)组橄。
所有的屏幕空間射線都是平行線,方向都是朝向正z軸(0,0,1)罚随,和物體進(jìn)入眼睛的光線正好相反玉工。 當(dāng)然這是一種默認(rèn)設(shè)置,OpenGL API中淘菩,默認(rèn)的NDC空間是left handed coordinate system遵班。屏幕射線總是射向物體。
這個定義跟通常所理解的稍有不同潮改,我最開始認(rèn)為屏幕射線會從原點(diǎn)出發(fā)狭郑,方向?yàn)閺脑c(diǎn)到這個屏幕點(diǎn)。實(shí)際上,屏幕射線只是起點(diǎn)反向延伸到原點(diǎn)。
屏幕射線這么定義的原因在于笛谦,從頂點(diǎn)到原點(diǎn)的射線從clip space轉(zhuǎn)化為ndc space后,變?yōu)槠叫芯€亩鬼。所以這個反向的屏幕射線這么定義是完全準(zhǔn)確的殖告。
Screen像素點(diǎn)和screen ray的坐標(biāo)變換
screen點(diǎn)()和screen-ray(0,0,1)通過反轉(zhuǎn)的viewport矩陣和投射矩陣,被變換為()雳锋,注意這種投射剛好將屏幕點(diǎn)投射到近平面之上黄绩,所以這個z=n是固定的。通過這個變換可以確定出clip space中近平面上的點(diǎn)()的計(jì)算公式魄缚。當(dāng)這個點(diǎn)確定后宝与,用這個點(diǎn)減去原點(diǎn),就得到screen-ray在clip space的方向矢量冶匹,然后由于z軸比為n习劫,可以消除n的存在。
隨后應(yīng)用eye matrix(view matrix的反轉(zhuǎn))嚼隘,將它們轉(zhuǎn)化為world coordinate诽里。然后再根據(jù)需要將其轉(zhuǎn)換為對應(yīng)的object coordinate。
屏幕射線的計(jì)算步驟
- 首先確定相機(jī)(eye frame)的原點(diǎn)(相機(jī)在world frame中的位置)
- 隨后計(jì)算點(diǎn)擊像素點(diǎn)坐標(biāo)在world frame中的坐標(biāo)
- 最后飞蛹,在world frame中使用點(diǎn)擊的像素點(diǎn)的世界坐標(biāo)減去相機(jī)位置谤狡,標(biāo)準(zhǔn)化后得到方向矢量。
屏幕射線在應(yīng)用中的設(shè)置
我們通常會生成一個RayCaster對象卧檐,這個對象用于生成屏幕射線墓懂。在應(yīng)用中,我們通常在點(diǎn)擊或者鼠標(biāo)事件中初始化RayCaster對象霉囚,RayCaster對象利用被點(diǎn)擊屏幕像素的坐標(biāo)初始化出一條屏幕射線捕仔。一般情況下,屏幕射線會以世界坐標(biāo)來表示盈罐,隨后所進(jìn)行的射線和幾何體相交檢測就會利用這條射線進(jìn)行榜跌。生成屏幕射線的部分代碼如下:
//借助相機(jī)數(shù)據(jù)生成world frame中的screen-ray
//屏幕點(diǎn)先是被對應(yīng)為near plane上的點(diǎn),然后再轉(zhuǎn)換為world coordinate
//也就是說先進(jìn)行反轉(zhuǎn)viewport計(jì)算盅粪,然后在應(yīng)用unporjection矩陣钓葫,最后應(yīng)用eye matrix(inverse view matrix)
void setFromCamera(Cvec3 screenPos,shared_ptr<PerspectiveCamera> camera){
//反轉(zhuǎn)viewport計(jì)算,由于窗口坐標(biāo)y軸的原點(diǎn)在窗口上方票顾,所以需要反轉(zhuǎn)符號
float rayOriginX = (screenPos[0]/camera->view.width) * 2 - 1;
float rayOriginY = -(screenPos[1]/camera->view.height) * 2 + 1;
if(camera){
Matrix4 eyeMat = camera->eyeMat;
Matrix4 projMat = camera->projMat;
Cvec3 camPosition = vec3(eyeMat(0,3),eyeMat(1,3),eyeMat(2,3));
Cvec4 screenPosWorld= (eyeMat*inv(projMat)) * vec4(rayOriginX,rayOriginY,1.0f,1.0f);
//反轉(zhuǎn)投射矩陣應(yīng)用后的坐標(biāo)仍為同質(zhì)坐標(biāo)础浮,需要執(zhí)行除法以獲得放射坐標(biāo)
screenPosWorld = screenPosWorld/screenPosWorld[3];
//screen ray方向矢量需要標(biāo)準(zhǔn)化
Cvec3 rayDi =normalize(vec3(screenPosWorld) -camPosition);
ray = new Ray(camPosition,rayDi);
}
}
射線和幾何體相交檢測(ray-geometry intersection test)
射線和幾何體相交檢測實(shí)際是要計(jì)算出屏幕射線和構(gòu)成幾何體的所有三角形是否相交,只要射線和其中至少一個三角形相交库物,我們就認(rèn)為射線和幾何體相交霸旗。但是由于構(gòu)成場景的幾何體以及幾何體本身的三角形數(shù)目很多,所以直接進(jìn)行射線三角形(ray-triangle)相交檢測的開銷很大戚揭。通常的方式是使用綁定容積(bounding volume)進(jìn)行保守檢測計(jì)算。bounding volume通常為圍繞幾何體的球體(sphere)或者方形體(box)撵枢,這些sphere或者box一般根據(jù)幾何體的頂點(diǎn)數(shù)據(jù)近似獲得民晒。
使用幾何體的頂點(diǎn)表達(dá)來獲得Bounding Volume精居,不管是sphere還是box,比較容易確定潜必。box一般是最大/最小頂點(diǎn)所構(gòu)成的AABB box靴姿。sphere一般先使用AABB box確定中心點(diǎn)的坐標(biāo),然后計(jì)算這個中心點(diǎn)和各個頂點(diǎn)的最大距離作為球體半徑磁滚,這樣所獲得的球體一般為更緊湊的球體(tighter sphere)佛吓。
ray-geometry相交檢測的原理
屏幕射線和幾何體相交檢測通常先執(zhí)行ray-sphere或者ray-box保守檢測,如果檢測不通過垂攘,則后續(xù)不會再執(zhí)行開銷大的ray-triangle檢測维雇。基本的執(zhí)行邏輯如下:
- 首先生成一個幾何體的bounding sphere晒他,執(zhí)行ray-sphere相交保守檢測吱型,如果不相交則舍棄整個檢查。
- 之后再執(zhí)行幾何體的ray-box相交檢測陨仅,再一次保守檢測津滞,逐漸精確。
- 最后執(zhí)行ray-triangle相交灼伤,這涉及到屏幕射線和每個三角形的相交檢測触徐,在cpu上執(zhí)行。ray-triangle要先計(jì)算barycentric坐標(biāo)u,v,w,其中u+v+q=1狐赡,u,v,w要都大于0撞鹉,射線才會和三角形相交,一般射線和幾何體相交猾警,相交點(diǎn)可能會大于1個孔祸,最小的t值為最先相交點(diǎn)。此處所計(jì)算出的相交點(diǎn)的精度是比較高的发皿。
ray-bv和ray-triangle檢測的部分移值代碼:
vector<vec3> Ray::intersectSphere(Sphere* sphere){
vec3 v1;
//兩個矢量相減產(chǎn)生新矢量v1崔慧,球體原點(diǎn)和射線原點(diǎn)的矢量
v1 = sphere->center - origin;
//使用標(biāo)準(zhǔn)矢量和非標(biāo)準(zhǔn)矢量的點(diǎn)積來計(jì)算余弦邊。
float tca = dot(v1,direction);
//d2是正弦邊的平方穴墅,v1平方構(gòu)成從相機(jī)位置和球體中心為最長邊平方惶室,tca2為余弦邊的平方,
float d2 = dot(v1,v1) - tca * tca;
//當(dāng)d2和radius2剛好相等時玄货,屏幕射線為球體切線皇钞,d2>raidus2時,屏幕射線和球體不相交
float radius2 = sphere->radius * sphere->radius;
//screen-ray和球體不相交
if ( d2 > radius2 ) return {};
float thc = sqrt( radius2 - d2 );
// t0*ray方向就等于從相機(jī)原點(diǎn)到球體表面相交點(diǎn)的距離
// t0 = first intersect point - entrance on front of sphere
float t0 = tca - thc;
// t1為到遠(yuǎn)距離點(diǎn)的距離
// t1 = second intersect point - exit point on back of sphere
float t1 = tca + thc;
// test to see if both t0 and t1 are behind the ray - if so, return null
if ( t0 < 0 && t1 < 0 ) return {};
// test to see if t0 is behind the ray:
// if it is, the ray is inside the sphere, so return the second exit point scaled by t1,
// in order to always return an intersect point that is in front of the ray.
if ( t0 < 0 ) return {this->at(t1)};
// else t0 is in front of the ray, so return the first collision point scaled by t0
return {this->at(t0)};
}
vector<vec3> Ray::intersectBox(Box* box){
float tmin, tmax, tymin, tymax, tzmin, tzmax;
float invdirx = 1 / direction[0],
invdiry = 1 / direction[1],
invdirz = 1 / direction[2];
if(invdirx >= 0){
tmin = (box->min[0] - origin[0]) * invdirx;
tmax = (box->max[0] - origin[0]) * invdirx;
}else{
tmin = (box->max[0] - origin[0]) * invdirx;
tmax = (box->min[0] - origin[0]) * invdirx;
}
if(invdiry >= 0 ) {
tymin = (box->min[1] - origin[1]) * invdiry;
tymax = (box->max[1] - origin[1]) * invdiry;
} else {
tymin = (box->max[1] - origin[1]) * invdiry;
tymax = (box->min[1] - origin[1]) * invdiry;
}
if ( ( tmin > tymax ) || ( tymin > tmax ) ) return {};
// These lines also handle the case where tmin or tmax is NaN
// (result of 0 * Infinity). x !== x returns true if x is NaN
if ( tymin > tmin || tmin != tmin ) tmin = tymin;
if ( tymax < tmax || tmax != tmax ) tmax = tymax;
if ( invdirz >= 0 ) {
tzmin = ( box->min[2] - origin[2] ) * invdirz;
tzmax = ( box->max[2] - origin[2] ) * invdirz;
} else {
tzmin = ( box->max[2] - origin[2] ) * invdirz;
tzmax = ( box->min[2] - origin[2] ) * invdirz;
}
if ( ( tmin > tzmax ) || ( tzmin > tmax ) ) return {};
if ( tzmin > tmin || tmin != tmin ) tmin = tzmin;
if ( tzmax < tmax || tmax != tmax ) tmax = tzmax;
//return point closest to the ray (positive side)
if ( tmax < 0 ) return {};
return {this->at( tmin >= 0 ? tmin : tmax)};
}
vector<vec3> Ray::intersectTriangle(vec3 a,vec3 b,vec3 c,bool backfaceCulling){
// Compute the offset origin, edges, and normal.
vec3 diff,edge1,edge2,normal;
// from http://www.geometrictools.com/GTEngine/Include/Mathematics/GteIntrRay3Triangle3.h
edge1 = b-a;
edge2 = c-a;
normal = cross(edge1, edge2);
//cross prodcut可以十分方便地應(yīng)用于determiant的計(jì)算
// Solve Q + t*D = b1*E1 + b2*E2 (Q = kDiff, D = ray direction,
// E1 = kEdge1, E2 = kEdge2, N = Cross(E1,E2)) by
// |Dot(D,N)|*b1 = sign(Dot(D,N))*Dot(D,Cross(Q,E2))
// |Dot(D,N)|*b2 = sign(Dot(D,N))*Dot(D,Cross(E1,Q))
// |Dot(D,N)|*t = -sign(Dot(D,N))*Dot(Q,N)
//確定符號松捉,是因?yàn)橐?jì)算位于正反面
float DdN = dot(direction,normal);
int sign;
if (DdN > 0){
if (backfaceCulling) return {};
sign = 1;
}else if( DdN < 0 ){
sign = - 1;
DdN = - DdN;
} else {
return {};
}
//此處Q指向相機(jī)原點(diǎn)夹界,于korea text方向相反,故而后面需要負(fù)值隘世。
diff = origin-a;
float DdQxE2 = sign * dot(direction,cross(diff, edge2));
// b1 < 0, no intersection
if ( DdQxE2 < 0 ) {
return {};
}
float DdE1xQ = sign * dot(direction,cross(edge1,diff));
// b2 < 0, no intersection
if ( DdE1xQ < 0 ) {
return {};
}
// b1+b2 > 1, no intersection
if ( DdQxE2 + DdE1xQ > DdN ) {
return {};
}
// Line intersects triangle, check if ray does.
float QdN = - sign * dot(diff,normal);
// t<0,則位于射線的反方向上可柿?
// t < 0, no intersection
if ( QdN < 0 ) {
return {};
}
// t值確定后鸠踪,就可以確定相交點(diǎn)。
// Ray intersects triangle.
return {this->at(QdN/DdN)};
}
real-time rendering書中對于具體的射線幾何體相交實(shí)現(xiàn)算法有十分詳細(xì)的講解复斥。
每條屏幕射線可能和場景中的多個物體相交营密。在本例中,我們只選擇最近相交的物體給予展示目锭。