Shadow volume实现细节
来源:互联网 发布:apk制作软件安卓版 编辑:程序博客网 时间:2024/05/17 00:52
背景介绍
背景介绍:目前阴影算法较为流行的有两种,分别是shadow mapping 和shadow volume。前者实现起来相对简单,可以发挥现在GPU可编程流水线的能力,但是由于先天不足,shadow mapping在处理动态光源/物体的时候开销过大,经常作为一种静态场景中的廉价替代物。而 Shadow volume 的强项恰恰是 shadow mapping 的短处,像 DOOM3 这种大量运用动态光源,并且要对时刻都在运动中的物体投射阴影,shadow volume是现阶段唯一的选择。
但是这里并不介绍shadow mapping,网上有很多shadow mapping详细的教程,比如这里有详细教程,而且附带源码,如果感兴趣可以看看。
同样,网上也有很多shadow volume的教程,但是很多仅仅描述了核心的算法思想,具体实现细节并没有给出。对于初次接触OpenGL,或者接触不久的人来说,实现起来有点难度,所以我不仅仅描述核心的算法思想,而且还给出具体的实现细节。希望对于初学者有所帮助。
核心思想
我是根据网上这个教程Shadow Volume核心算法详解学习的,这个教程对于Shadow Volume的核心算法介绍的很详细。虽然很详细,但是为了加深我自己的理解,我决定也要写一下。
算法思想:就是光源照射到物体上,在物体的后面会形成一个阴影空间,如图所示
人物模型后面的蓝色空间,和机车后面的黑色空间,就是上面所说的阴影空间。然后再判断片段是否在这个阴影空间中,如果在这个阴影空间中,那么说明这个片段是有阴影的。
梳理一下,就是
- 生成阴影体
- 如何判断片段是否在阴影空间中(这部分是Shadow Volume的核心)。
- 如何将处于阴影体中的片段渲染成阴影。
第一个问题,如何生成阴影体
首先解决第一个问题如何生成阴影体?
如上图所示,光照射物体得到的阴影空间(阴影体),是沿着光照射的物体轮廓而产生的。建立的数学模型就是,物体轮廓的每一个点,沿着光线方向向下延伸到无限远处,然后轮廓所有点得到的延伸线组成一个封闭的空间,这就是所谓的阴影空间(阴影体)。
梳理一下,就是
- 寻找物体的轮廓
- 轮廓的点,沿着光线方向,向下无限延伸,并组合成一个封闭的空间
那么,下面咱们就解决这两个问题之一,生成一个阴影体。
1:寻找物体轮廓的方法。
要想得到物体的轮廓,我们必须要学习一个绘图方式GL_STRANGLE_ADJACENCY。这个绘图方式是OpenGL新增的。当使用GL_STRANGLE_ADJACENCY绘图的时候,传入着色器的数据不仅仅包括三角面片,还包括三角面片的邻接面。如下图所示。
中间实线的三角形是原三角面片,周围虚线的是三角面的邻接三角面。
那么这里存在一个问题。使用GL_STRANGLE方式绘图的时候,仅仅需要上图实线三角面的数据,而使用GL_STRANGLE_ADJACENCY方式绘图的时候,使用上图一个实线三角面和虚线三角面的数据。但是使用Assimp仅仅可以将obj数据导入成GL_STRANGLE格式的,那么如何将GL_STRANGLE换成GL_STRANGLE_ADJACENCY格式的呢?
那就需要将将要存入VAO中的数据扩大二倍,即原先3个顶点的,扩大到6个顶点,以便存放邻接信息,此外还需要更新索引,以便着色器每次可以将三角面片以及三角面片的邻接面片全部取出。
下面给出了实现方法:
myModel(const string& Filename, bool WithAdjacencies) { m_withAdjacencies = WithAdjacencies; Clear(); glGenVertexArrays(1, &m_VAO); glBindVertexArray(m_VAO); // Create the buffers for the vertices attributes glGenBuffers(ARRAY_SIZE_IN_ELEMENTS(m_Buffers), m_Buffers); m_pScene = m_Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs | aiProcess_JoinIdenticalVertices); if (m_pScene) { InitFromScene(m_pScene, Filename); } else { printf("Error parsing '%s': '%s'\n", Filename.c_str(), m_Importer.GetErrorString()); } glBindVertexArray(0); }bool InitFromScene(const aiScene* pScene, const string& Filename) { m_Entries.resize(pScene->mNumMeshes); m_Textures.resize(pScene->mNumMaterials); vector<Vector3f> Positions; vector<Vector3f> Normals; vector<Vector2f> TexCoords; vector<uint> Indices; uint NumVertices = 0; uint NumIndices = 0; uint VerticesPerPrim = m_withAdjacencies ? 6 : 3; // Count the number of vertices and indices for (uint i = 0; i < m_Entries.size(); i++) { m_Entries[i].MaterialIndex = pScene->mMeshes[i]->mMaterialIndex; m_Entries[i].NumIndices = pScene->mMeshes[i]->mNumFaces * VerticesPerPrim; m_Entries[i].BaseVertex = NumVertices; m_Entries[i].BaseIndex = NumIndices; NumVertices += pScene->mMeshes[i]->mNumVertices; NumIndices += m_Entries[i].NumIndices; } // Reserve space in the vectors for the vertex attributes and indices Positions.reserve(NumVertices); Normals.reserve(NumVertices); TexCoords.reserve(NumVertices); Indices.reserve(NumIndices); // Initialize the meshes in the scene one by one for (uint i = 0; i < m_Entries.size(); i++) { const aiMesh* paiMesh = pScene->mMeshes[i]; InitMesh(i, paiMesh, Positions, Normals, TexCoords, Indices); } // Generate and populate the buffers with vertex attributes and the indices glBindBuffer(GL_ARRAY_BUFFER, m_Buffers[POS_VB]); glBufferData(GL_ARRAY_BUFFER, sizeof(Positions[0]) * Positions.size(), &Positions[0], GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer(GL_ARRAY_BUFFER, m_Buffers[TEXCOORD_VB]); glBufferData(GL_ARRAY_BUFFER, sizeof(TexCoords[0]) * TexCoords.size(), &TexCoords[0], GL_STATIC_DRAW); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer(GL_ARRAY_BUFFER, m_Buffers[NORMAL_VB]); glBufferData(GL_ARRAY_BUFFER, sizeof(Normals[0]) * Normals.size(), &Normals[0], GL_STATIC_DRAW); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 0, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Buffers[INDEX_BUFFER]); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(Indices[0]) * Indices.size(), &Indices[0], GL_STATIC_DRAW); return GLCheckError(); } void FindAdjacencies(const aiMesh* paiMesh, vector<unsigned int>& Indices) { // Step 1 - find the two triangles that share every edge for (uint i = 0; i < paiMesh->mNumFaces; i++) { const aiFace& face = paiMesh->mFaces[i]; Face Unique; // If a position vector is duplicated in the VB we fetch the // index of the first occurrence. for (uint j = 0; j < 3; j++) { uint Index = face.mIndices[j]; aiVector3D& v = paiMesh->mVertices[Index]; if (m_posMap.find(v) == m_posMap.end()) { m_posMap[v] = Index; } else { Index = m_posMap[v]; } Unique.Indices[j] = Index; } m_uniqueFaces.push_back(Unique); Edge e1(Unique.Indices[0], Unique.Indices[1]); Edge e2(Unique.Indices[1], Unique.Indices[2]); Edge e3(Unique.Indices[2], Unique.Indices[0]); m_indexMap[e1].AddNeigbor(i); m_indexMap[e2].AddNeigbor(i); m_indexMap[e3].AddNeigbor(i); } // Step 2 - build the index buffer with the adjacency info for (uint i = 0; i < paiMesh->mNumFaces; i++) { const Face& face = m_uniqueFaces[i]; for (uint j = 0; j < 3; j++) { Edge e(face.Indices[j], face.Indices[(j + 1) % 3]); assert(m_indexMap.find(e) != m_indexMap.end()); Neighbors n = m_indexMap[e]; uint OtherTri = n.GetOther(i); assert(OtherTri != -1); const Face& OtherFace = m_uniqueFaces[OtherTri]; uint OppositeIndex = OtherFace.GetOppositeIndex(e); Indices.push_back(face.Indices[j]); Indices.push_back(OppositeIndex); } } } void InitMesh(uint MeshIndex, const aiMesh* paiMesh, vector<Vector3f>& Positions, vector<Vector3f>& Normals, vector<Vector2f>& TexCoords, vector<uint>& Indices) { const aiVector3D Zero3D(0.0f, 0.0f, 0.0f); // Populate the vertex attribute vectors for (uint i = 0; i < paiMesh->mNumVertices; i++) { const aiVector3D* pPos = &(paiMesh->mVertices[i]); const aiVector3D* pNormal = &(paiMesh->mNormals[i]); const aiVector3D* pTexCoord = paiMesh->HasTextureCoords(0) ? &(paiMesh->mTextureCoords[0][i]) : &Zero3D; Positions.push_back(Vector3f(pPos->x, pPos->y, pPos->z)); Normals.push_back(Vector3f(pNormal->x, pNormal->y, pNormal->z)); TexCoords.push_back(Vector2f(pTexCoord->x, pTexCoord->y)); } if (m_withAdjacencies) { FindAdjacencies(paiMesh, Indices); } else { // Populate the index buffer for (uint i = 0; i < paiMesh->mNumFaces; i++) { const aiFace& Face = paiMesh->mFaces[i]; assert(Face.mNumIndices == 3); Indices.push_back(Face.mIndices[0]); Indices.push_back(Face.mIndices[1]); Indices.push_back(Face.mIndices[2]); } } }
通过上面的代码可以将VAO中的GL_STRANGLE数据转变为GL_STRANGLE_ADJACENCY数据。
已经将VAO中的数据转化为GL_STRANGLE_ADJACENCY数据了,那么现在告诉你为什么转变成这种格式。怎么由这种格式可以判断出物体的轮廓。
再看这幅图,我们可以通过光线与法线的夹角来判断三角面的边属不属于轮廓!!!
主要思想是:判断当入射光线与024三角面的法线的点乘为正,入射光线与024三角面邻接三角面012三角面的法线的点乘 为负的时候,说明这两个三角面的公用边02为轮廓。
自然解释为,考虑自然界阴影轮廓,轮廓的一面为光照面,也就是光线能照射到三角面片上,光线和法线的点乘为正。轮廓的另一面为阴影面,也就是光源照射不到的地方,则光线和法线的点乘为负。
那么可以通过这个条件来判断某个边是否为轮廓。一般判断操作在几何着色器中进行,该部分代码如下所示。
void main(){ vec3 e1 = PosL[2] - PosL[0]; vec3 e2 = PosL[4] - PosL[0]; vec3 e3 = PosL[1] - PosL[0]; vec3 e4 = PosL[3] - PosL[2]; vec3 e5 = PosL[4] - PosL[2]; vec3 e6 = PosL[5] - PosL[0]; vec3 Normal = normalize(cross(e1,e2)); vec3 LightDir = normalize(gLightPos - PosL[0]); // Handle only light facing triangles if (dot(Normal, LightDir) > 0) { Normal = cross(e3,e1); //判断是否是物体轮廓边界,如果是则将该边界的两端顶点进行处理 //(沿着光线方向生成一个四边形面,并与其它轮廓生成的面组合成阴影体)。 if (dot(Normal, LightDir) <= 0) { vec3 StartVertex = PosL[0]; vec3 EndVertex = PosL[2]; EmitQuad(StartVertex, EndVertex); } Normal = cross(e4,e5); LightDir = gLightPos - PosL[2]; if (dot(Normal, LightDir) <= 0) { vec3 StartVertex = PosL[2]; vec3 EndVertex = PosL[4]; EmitQuad(StartVertex, EndVertex); } Normal = cross(e2,e6); LightDir = gLightPos - PosL[4]; if (dot(Normal, LightDir) <= 0) { vec3 StartVertex = PosL[4]; vec3 EndVertex = PosL[0]; EmitQuad(StartVertex, EndVertex); } // render the front cap LightDir = (normalize(PosL[0] - gLightPos)); gl_Position = gWVP * vec4((PosL[0] + LightDir * EPSILON), 1.0); EmitVertex(); LightDir = (normalize(PosL[2] - gLightPos)); gl_Position = gWVP * vec4((PosL[2] + LightDir * EPSILON), 1.0); EmitVertex(); LightDir = (normalize(PosL[4] - gLightPos)); gl_Position = gWVP * vec4((PosL[4] + LightDir * EPSILON), 1.0); EmitVertex(); EndPrimitive(); // render the back cap LightDir = PosL[0] - gLightPos; gl_Position = gWVP * vec4(LightDir, 0.0); EmitVertex(); LightDir = PosL[4] - gLightPos; gl_Position = gWVP * vec4(LightDir, 0.0); EmitVertex(); LightDir = PosL[2] - gLightPos; gl_Position = gWVP * vec4(LightDir, 0.0); EmitVertex(); EndPrimitive(); } }
2:轮廓的点,沿着光线方向,向下无限延伸,并组合成一个封闭的空间,形成一个阴影体。
通过上面算法,我们可以得到一个物体的轮廓边界,下面我们要通过这个轮廓来生成一个阴影空间(阴影体)。具体细节如下图所示,
V0和V1是检测出来的物体轮廓两端点的顶点,V3,V2分别是V0,V1沿光线方向向远处延伸的顶点,通过这样的无限延伸,我们能够确保阴影体能够将所有位于阴影中的对象都包含在内。当我们对组成轮廓的所有边都进行了上面的处理之后,阴影体的四周就创建成功了。但是阴影体需要时一个封闭的空间,所以还需要建立两个盖子,分别位于阴影体的上面和下面。此时阴影体就创建好了,这都是在几何着色器中创建的。
具体实现的
代码如下:
void EmitQuad(vec3 StartVertex, vec3 EndVertex){ // Vertex #1: the starting vertex (just a tiny bit below the original edge) vec3 LightDir = normalize(StartVertex - gLightPos); gl_Position = gWVP * vec4((StartVertex + LightDir * EPSILON), 1.0); EmitVertex(); // Vertex #2: the starting vertex projected to infinity gl_Position = gWVP * vec4(LightDir, 0.0); EmitVertex(); // Vertex #3: the ending vertex (just a tiny bit below the original edge) LightDir = normalize(EndVertex - gLightPos); gl_Position = gWVP * vec4((EndVertex + LightDir * EPSILON), 1.0); EmitVertex(); // Vertex #4: the ending vertex projected to infinity gl_Position = gWVP * vec4(LightDir , 0.0); EmitVertex(); EndPrimitive(); }
第二个问题
此时我们已经解决了第一个问题如何生成阴影体。接下来解决第二个问题,如何判断片段是否在阴影体中。
这部分是Shadow Volume的核心,也是实现Shadow Volume的精髓所在,这就是z-fail算法。下面通过这幅图来介绍一下什么是z-fail算法,以及该算法是如何判断片段是否处于阴影中。
现在让我们看看如何利用模板缓存来产生阴影。首先我们像平常一样将场景对象(A, B,C 以及绿色的盒子)渲染到深度缓存中,完成之后我们就能得到场景中离相机最近的所有像素。之后我们遍历场景中的所有对象并为每个对象创建一个阴影体,上面的图例中只显示了绿色盒子的阴影体,但是在一个完整的程序中我们会为每个圆形对象都创建一个阴影体,因为他们也会产生阴影。阴影体的创建是根据检测出的轮廓(参考 39 课中的内容)并将其扩展到三维空间中。之后根据下面的一些简单规则将阴影体渲染到模板缓存中:
- 在渲染阴影体反面时,如果深度测试失败则使模板缓存中的值加一;
- 在渲染阴影体正面时,如果深度测试失败则是模板缓存中的值减一;
- 在下面情况时我们不做任何处理:深度测试通过、模板测试失败。
上图所示。假设A,B,C的物体的初始模板值都是0。首先渲染阴影体反面时候,A和B物体位于阴影体背面的前方,所以可以通过深度测试,此时模板值A=0,B=0;C位于阴影体的后面,所以并没有通过深度测试,那么C片段的模板值C=1;然后再渲染阴影体正面,A处于阴影体正面的前方,所以通过深度测试,不作操作,此时模板值A=0,B和C位于阴影体正面的后方,所以深度测试失败,那么模板缓存中该片段的值减一。也就是B=-1,C=0;此时每个片段都被模板值标记,标记为非0的就是阴影区域。
这就是Z-fail的基本算法,当然这仅仅是最简单的情况,仅仅有一个阴影体。但是对于多个阴影体也是一样的,例如下图
已经知道思想,那么我们开始着手实现他。
首先我们需要渲染整个场景,对整个场景进行深度测试,用于下面的模板测试。开启深度测试,并打开深度缓冲写入功能,关闭颜色缓冲写入。
////将场景渲染到深度缓存里面 glDepthMask(GL_TRUE); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); //glDrawBuffer(GL_NONE);//glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); depthShader.Use(); glUniformMatrix4fv(glGetUniformLocation(depthShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); glUniformMatrix4fv(glGetUniformLocation(depthShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); //Draw the loaded model model = glm::translate(model, glm::vec3(0.0f, -3.5f, -1.0f)); model = glm::rotate(model, glm::radians(90.0f), glm::normalize(glm::vec3(1.0, 0.0, 0.0))); model = glm::scale(model, glm::vec3(10.0, 10.0, 0.2)); glUniformMatrix4fv(glGetUniformLocation(depthShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); //boxModel.Draw(depthShader, false); MyModelMeshF.Render(); model = glm::mat4(); model = glm::translate(model, glm::vec3(0.0f, -1.5f, -1.0f)); model = glm::rotate(model, glm::radians((GLfloat)glfwGetTime() * 50.0f), glm::normalize(glm::vec3(0.0, 1.0, 0.0))); glUniformMatrix4fv(glGetUniformLocation(depthShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); //boxModel.Draw(depthShader, false); MyModelMeshF.Render();
获得深度缓冲是必要的,因为深度缓存值也是Z-fail算法的基础,只有当深度测试没有通过,才对该片段的模板值进行加减。此时整个场景的深度值已经保存到深度缓冲中了,下面我们开始渲染阴影体。
开启模板测试,并且禁止深度值写入深度缓冲,关闭颜色缓冲写入。
//将场景渲染到模板缓存里面 glEnable(GL_STENCIL_TEST); glDepthMask(GL_FALSE); glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); glEnable(GL_DEPTH_CLAMP); glDisable(GL_CULL_FACE); glStencilFunc(GL_ALWAYS, 0, 0xff); glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP); glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP); stencilShader.Use(); glUniformMatrix4fv(glGetUniformLocation(stencilShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); glUniformMatrix4fv(glGetUniformLocation(stencilShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); model = glm::mat4(); model = glm::translate(model, glm::vec3(0.0f, -1.5f, -1.0f)); model = glm::rotate(model, glm::radians((GLfloat)glfwGetTime() * 50.0f), glm::normalize(glm::vec3(0.0, 1.0, 0.0))); //model = glm::translate(model, glm::vec3(0.0f, -2.5f, 0.0)); glUniformMatrix4fv(glGetUniformLocation(stencilShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); glm::mat4 gWVP = projection * view * model; glUniformMatrix4fv(glGetUniformLocation(stencilShader.Program, "gWVP"), 1, GL_FALSE, glm::value_ptr(gWVP)); //zhuziModel.Draw(stencilShader, true); MyModelMeshT.Render(); glDisable(GL_DEPTH_CLAMP); glEnable(GL_CULL_FACE);
此时已经执行了z-fail算法,更新了不同片段的模板值,处于阴影体内的模板值非0,未处于阴影体内的模板值为0。然后在进行模板测试,对于模板值非0的片段不进行着色。具体实现如下:
//渲染带阴影场景 glDepthMask(GL_TRUE); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); glStencilFunc(GL_EQUAL, 0x0, 0xFF); glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); sceneShader.Use(); glUniformMatrix4fv(glGetUniformLocation(sceneShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); glUniformMatrix4fv(glGetUniformLocation(sceneShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniform3fv(glGetUniformLocation(sceneShader.Program, "viewPos"), 1, &cameraPos[0]); //glm::mat4 model; model = glm::mat4(); model = glm::translate(model, glm::vec3(0.0f, -3.5f, -1.0f)); model = glm::rotate(model, glm::radians(90.0f), glm::normalize(glm::vec3(1.0, 0.0, 0.0))); model = glm::scale(model, glm::vec3(10.0, 10.0, 0.2)); glUniformMatrix4fv(glGetUniformLocation(sceneShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); //boxModel.Draw(sceneShader, false); MyModelMeshF.Render(); model = glm::mat4(); model = glm::translate(model, glm::vec3(0.0f, -1.5f, -1.0f)); model = glm::rotate(model, glm::radians((GLfloat)glfwGetTime() * 50.0f), glm::normalize(glm::vec3(0.0, 1.0, 0.0))); glUniformMatrix4fv(glGetUniformLocation(sceneShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); //boxModel.Draw(sceneShader, false); MyModelMeshF.Render(); glDisable(GL_STENCIL_TEST);
shadow volume的算法基本上已经完成,具体效果如下:
再梳理一下整个文章脉络。
- 将可以使用GL_STRANGLE绘图的数据转化为可以使用GL_STRSNGLE_ADJACENCY绘图的数据
- 判断物体是每个三角面片的边是否物体轮廓
- 根据上面所得的轮廓的两端顶点,沿着光线方向在无限远处生成一个顶点,这四个顶点组成一个四边形,所有四边形的几何,将组合成一个阴影空间(阴影体)。
- 渲染整个场景到深度缓冲
- 渲染阴影体到模板缓冲
- 启动模板测试渲染场景,模板值为非零的不渲染
有源码,如果做不出来可以留言问我要。
- Shadow volume实现细节
- Shadow Map & Shadow Volume
- Shadow Map & Shadow Volume
- Shadow Map & Shadow Volume
- unity中基于alpha通道的shadow volume实现
- Shadow Volume DX8
- 阴影锥(Shadow Volume)
- 阴影体(shadow volume)
- 最简单的shadow volume
- 阴影二- shadow volume 原理
- Stencil Shadow Volume技术讲解
- 【转】阴影锥(shadow volume)原理与展望---真实的游戏效果的实现
- 阴影锥(shadow volume)原理与展望---真实的游戏效果的实现
- ZFXEngine开发笔记之Shadow Volume
- 突然彻底明白了Stencil Shadow Volume的原理
- Stencil Shadow Volume的Z-pass和Z-fail算法
- Working with the Windows Server 2003 Volume Shadow Copy Service
- 计算用于阴影剔除的包围体(shadow culling volume)
- 再看CNN中的卷积
- 关于storyboard中scrollerview的问题
- Win7下IDEA+Maven+git开发环境配置
- Java将Exception信息转为String字符串
- 深入理解Java:注解(Annotation)自定义注解入门
- Shadow volume实现细节
- mysql_real_escape_string函数预防数据库攻击。
- 【深度学习】CNN-原理
- springmvc4 url pattern for 404
- Linux使用心得(一)
- iOS OC消除黄色警告⚠️ (不断的更新中...)
- mysql 导入sql 文件 的错误类型 150和121
- USB基本术语
- MongoDB Replica set 集群搭建