引言

在上一篇文章中,我们完成了多模型多材质场景的渲染,此时我们已经可以自由地为场景添加很多模型了。但是一个场景如果只有简单的方块形状,而且没有图案,那渲染出来的效果可谓不堪入目,实在对不起optix底层这么大的算力。所以这一节,我们逐步引入大型模型、纹理映射、追加阴影透射等内容。

Part1. 复杂模型导入

导入外部模型需要借助第三方库tiny_obj_loader,调用其LoadObj读取.obj和.mtl文件便可得到所有顶点、索引、法线和材质了。但是,事实上并没有这么简单,因为我们要考虑一件事:一个TriangleMesh对应一个shader Record(即材质)。如果导入的obj是一个模型,但是它身上有多个材质,那么我们将不得不对这个模型进行拆分成多个TriangleMesh,从而映射多套材质。

这是一个相当麻烦的过程,所以我们新建了个Model.h/Model.cpp文件来专门处理模型导入和拆分的事。

Model.h:

#pragma once

#include "gdt/math/AffineSpace.h"
#include <vector>

/*! \namespace osc - Optix Siggraph Course */
namespace osc {
    using namespace gdt;

    /*! a simple indexed triangle mesh that our sample renderer will
        render */
    struct TriangleMesh {
        std::vector<vec3f> vertex;
        std::vector<vec3f> normal;
        std::vector<vec2f> texcoord;
        std::vector<vec3i> index;

        // material data:
        vec3f              diffuse;
    };

    struct Model {
        ~Model()
        {
            for (auto mesh : meshes) delete mesh;
        }

        std::vector<TriangleMesh*> meshes;
        //! bounding box of all vertices in the model
        box3f bounds;
    };

    Model* loadOBJ(const std::string& objFile);
}

首先我们将TriangleMesh的定义移动到Model.h中,并重新定义它的成员参数:vertex(顶点)、normal(法线)、texcoord(uv)、index(三角面索引),这里要注意vertex[k]和normal[k]和texcoord[k]是一一对应的,共同表示模型中索引号为k的点的属性,而index就是存每个三角形中使用顶点的索引号。这里又预存了一个diffuse(漫反射颜色,当然我个人不是很喜欢把材质的内容直接定义在模型结构体内部)

后面定义了一个Model结构体,Model指的是导入的单个模型文件,用meshs管理所有拆分后的模型;bounds是一个包围盒,理论上包围盒需要根据模型的所有顶点计算出模型所覆盖的空间,这样就可以计算出模型的中心点在哪里了。

接下来我们要用loadOBJ来读取.obj文件了,在读取之前,我们还要对.obj文件有一点额外的了解:首先,一个obj文件中可能会有多个shape;顶点、法线、uv、三角面都是分shape存储的。什么是shape呢?比如我们在Blender中创建了一个立方体物体,一个圆柱物体,导出的时候我们把这两个物体导出到同一个.obj中了,那么此时.obj物体就拥有两个shape;而对于每个shape,可以拥有多个材质通道。所以这个.obj中存在多少个材质通道,就需要把.obj的模型切分成多少个TriangleMesh。

我们慢慢来看Model.cpp中的内容:

#include "Model.h"
#define TINYOBJLOADER_IMPLEMENTATION
#include "3rdParty/tiny_obj_loader.h"
//std
#include <set>

namespace std {
  inline bool operator<(const tinyobj::index_t &a,
                        const tinyobj::index_t &b)
  {
    if (a.vertex_index < b.vertex_index) return true;
    if (a.vertex_index > b.vertex_index) return false;
    
    if (a.normal_index < b.normal_index) return true;
    if (a.normal_index > b.normal_index) return false;
    
    if (a.texcoord_index < b.texcoord_index) return true;
    if (a.texcoord_index > b.texcoord_index) return false;
    
    return false;
  }
}

首先导入模型库,后面这一段是用来给index定义大小关系的,然而没有看到哪里用到<这个operator了。。。我们直接无视它。

Model *loadOBJ(const std::string &objFile)
  {
    Model *model = new Model;

    const std::string mtlDir
      = objFile.substr(0,objFile.rfind('/')+1);
    PRINT(mtlDir);
    
    tinyobj::attrib_t attributes;
    std::vector<tinyobj::shape_t> shapes;
    std::vector<tinyobj::material_t> materials;
    std::string err = "";

    bool readOK
      = tinyobj::LoadObj(&attributes,
                         &shapes,
                         &materials,
                         &err,
                         &err,
						 objFile.c_str(),
                         mtlDir.c_str(),
                         /* triangulate */true);
    if (!readOK) {
      throw std::runtime_error("Could not read OBJ model from "+objFile+":"+mtlDir+" : "+err);
    }

    if (materials.empty())
      throw std::runtime_error("could not parse materials ...");

    std::cout << "Done loading obj file - found " << shapes.size() << " shapes with " << materials.size() << " materials" << std::endl;
    for (int shapeID=0;shapeID<(int)shapes.size();shapeID++) {
      tinyobj::shape_t &shape = shapes[shapeID];

      std::set<int> materialIDs;
      for (auto faceMatID : shape.mesh.material_ids)
        materialIDs.insert(faceMatID);
      
      std::map<tinyobj::index_t,int> knownVertices;
      
      for (int materialID : materialIDs) {
        TriangleMesh *mesh = new TriangleMesh;
        
        for (int faceID=0;faceID<shape.mesh.material_ids.size();faceID++) {
          if (shape.mesh.material_ids[faceID] != materialID) continue;
          tinyobj::index_t idx0 = shape.mesh.indices[3*faceID+0];
          tinyobj::index_t idx1 = shape.mesh.indices[3*faceID+1];
          tinyobj::index_t idx2 = shape.mesh.indices[3*faceID+2];
          
          vec3i idx(addVertex(mesh, attributes, idx0, knownVertices),
                    addVertex(mesh, attributes, idx1, knownVertices),
                    addVertex(mesh, attributes, idx2, knownVertices));
          mesh->index.push_back(idx);
          mesh->diffuse = (const vec3f&)materials[materialID].diffuse;
          mesh->diffuse = gdt::randomColor(materialID);
        }

        if (mesh->vertex.empty())
          delete mesh;
        else
          model->meshes.push_back(mesh);
      }
    }

    // of course, you should be using tbb::parallel_for for stuff
    // like this:
    for (auto mesh : model->meshes)
      for (auto vtx : mesh->vertex)
        model->bounds.extend(vtx);

    std::cout << "created a total of " << model->meshes.size() << " meshes" << std::endl;
    return model;
  }

loadOBJ函数就是读取、处理模型的函数。首先我们调用tinyObj的LoadObj接口来打开模型,此时会将模型的所有顶点、法线、uv、索引信息存入attributes;将obj中的各个shape存入shapes;将所有材质信息存入materials。

接下来,我们需要按照材质来拆分模型(一个材质对应一个TriangleMesh)。首先一个obj文件有若干个shapes;一个shapes又有若干个材质。所以需要遍历所有shapes;在shapes中,再遍历该shapes内的所有material,对于每一个material,我们创建一个新的TriangleMesh,这里就要开始拆分了,我们遍历该shapes下每一个 材质是当前所遍历材质 的面,我们可以直接得到这个面的索引信息(包括三个顶点的编号、三个顶点的uv号、三个顶点的法线号),然后把它们“塞入”这个新TriangleMesh里。

逻辑其实比较明确,只是怎么“塞入”稍显复杂。这里我们创建了一个addVertex函数,来完成塞入:

int addVertex(TriangleMesh *mesh,
                tinyobj::attrib_t &attributes,
                const tinyobj::index_t &idx,
                std::map<tinyobj::index_t,int> &knownVertices)
  {
    if (knownVertices.find(idx) != knownVertices.end())
      return knownVertices[idx];

    const vec3f *vertex_array   = (const vec3f*)attributes.vertices.data();
    const vec3f *normal_array   = (const vec3f*)attributes.normals.data();
    const vec2f *texcoord_array = (const vec2f*)attributes.texcoords.data();
    
    int newID = mesh->vertex.size();
    knownVertices[idx] = newID;

    mesh->vertex.push_back(vertex_array[idx.vertex_index]);
    if (idx.normal_index >= 0) {
      while (mesh->normal.size() < mesh->vertex.size())
        mesh->normal.push_back(normal_array[idx.normal_index]);
    }
    if (idx.texcoord_index >= 0) {
      while (mesh->texcoord.size() < mesh->vertex.size())
        mesh->texcoord.push_back(texcoord_array[idx.texcoord_index]);
    }

    // just for sanity's sake:
    if (mesh->texcoord.size() > 0)
      mesh->texcoord.resize(mesh->vertex.size());
    // just for sanity's sake:
    if (mesh->normal.size() > 0)
      mesh->normal.resize(mesh->vertex.size());
    
    return newID;
  }

addVertex函数需要几个传参:首先需要我们新建的TriangleMesh,然后是obj模型的全部信息attributes,然后是当前遍历到的面的三个索引信息(包括选用几号顶点、几号uv、几号法线),以及一个检查是否有重复索引的map结构knownVertices。

刚进函数时,首先要判断当前这个idx索引信息是否已经有了(即已经出现在了knownVertices里),如果有了,那我们就不需要创建新顶点,直接返回模型的索引编号即可;如果发现这个索引信息还没有,那我们就向TriangleMesh中放入这个新的顶点信息(包括顶点坐标、uv值、法线方向,这些都在attributes里能找到),然后把他记录在knownVertices中;如果之后有三角形的某个点又用到了相同的索引,就直接返回这个索引编号即可。

addVertex会返回新顶点信息的索引号,这样连续三个索引号就可以构成一个TriangleMesh的新三角面了。最后,我们遍历所有顶点来计算bounds的空间范围,方便后面找模型的中心位置。

现在我们将场景中的meshes组改成了Model类,SampleRenderer的成员参数和构造都要跟着改,这里都是细枝末节了所以就不多赘述代码怎么改了。最后修改一下main.cpp实现模型导入:

main.cpp:

extern "C" int main(int ac, char** av)
    {
        try {
            Model* model = loadOBJ("../../models/sponza.obj");
            Camera camera = { /*from*/vec3f(-1293.07f, 154.681f, -0.7304f),
                /* at */model->bounds.center() - vec3f(0,400,0),
                /* up */vec3f(0.f,1.f,0.f) };
            // something approximating the scale of the world, so the
            // camera knows how much to move for any given user interaction:
            const float worldScale = length(model->bounds.span());

            SampleWindow* window = new SampleWindow("Optix 7 Course Example",
                model, camera, worldScale);
            window->run();

        }
        catch (std::runtime_error& e) {
            std::cout << GDT_TERMINAL_RED << "FATAL ERROR: " << e.what()
                << GDT_TERMINAL_DEFAULT << std::endl;
            std::cout << "Did you forget to copy sponza.obj and sponza.mtl into your optix7course/models directory?" << std::endl;
            exit(1);
        }
        return 0;
    }

最后在McGuire Computer Graphics Archive (casual-effects.com)网站下,下载一个场景模型放到对应目录,渲染:

Part.2 纹理导入

纹理是ClosetHit Shader用来计算某一点的漫射颜色的,这意味着我们需要在HitGroup Record的data中绑定纹理。由于data是我们自定义的TriangleMeshSBTData结构,因此修改TriangleMeshSBTData:

struct TriangleMeshSBTData {
    vec3f  color;
    vec3f *vertex;
    vec3f *normal;
    vec2f *texcoord;
    vec3i *index;
    bool                hasTexture;
    cudaTextureObject_t texture;
  };

这里我们把法线、uv和纹理全部传入,这样在ClosetHit Shader中可以获取的信息就更丰富了。

接下来,我们向Model和TriangleMesh中也引入纹理的概念:

Model.h:

struct TriangleMesh {
    std::vector<vec3f> vertex;
    std::vector<vec3f> normal;
    std::vector<vec2f> texcoord;
    std::vector<vec3i> index;

    // material data:
    vec3f              diffuse;
    int                diffuseTextureID { -1 };
  };

  struct Texture {
    ~Texture()
    { if (pixel) delete[] pixel; }
    
    uint32_t *pixel      { nullptr };
    vec2i     resolution { -1 };
  };

  struct Model {
    ~Model()
    {
      for (auto mesh : meshes) delete mesh;
      for (auto texture : textures) delete texture;
    }
    
    std::vector<TriangleMesh *> meshes;
    std::vector<Texture *>      textures;
    //! bounding box of all vertices in the model
    box3f bounds;
  };

TriangleMesh中加入了一项diffuseTextureID表示当前模型用的是几号贴图,这样在绑定SBT的时候方便向HitGroup Record->data中的texture赋值。同时定义了一个Texture架构体,设置了一个分辨率参数,以及贴图像素指针。对于Model结构体,增加一个textures数组,用来装载该obj所涉及的全部贴图。

接下来编写加载贴图的函数:

int loadTexture(Model *model,
                  std::map<std::string,int> &knownTextures,
                  const std::string &inFileName,
                  const std::string &modelPath)
  {
    if (inFileName == "")
      return -1;
    
    if (knownTextures.find(inFileName) != knownTextures.end())
      return knownTextures[inFileName];

    std::string fileName = inFileName;
    // first, fix backspaces:
    for (auto &c : fileName)
      if (c == '\\') c = '/';
    fileName = modelPath+"/"+fileName;

    vec2i res;
    int   comp;
    unsigned char* image = stbi_load(fileName.c_str(),
                                     &res.x, &res.y, &comp, STBI_rgb_alpha);
    int textureID = -1;
    if (image) {
      textureID = (int)model->textures.size();
      Texture *texture = new Texture;
      texture->resolution = res;
      texture->pixel      = (uint32_t*)image;

      /* iw - actually, it seems that stbi loads the pictures
         mirrored along the y axis - mirror them here */
      for (int y=0;y<res.y/2;y++) {
        uint32_t *line_y = texture->pixel + y * res.x;
        uint32_t *mirrored_y = texture->pixel + (res.y-1-y) * res.x;
        int mirror_y = res.y-1-y;
        for (int x=0;x<res.x;x++) {
          std::swap(line_y[x],mirrored_y[x]);
        }
      }
      
      model->textures.push_back(texture);
    } else {
      std::cout << GDT_TERMINAL_RED
                << "Could not load texture from " << fileName << "!"
                << GDT_TERMINAL_DEFAULT << std::endl;
    }
    
    knownTextures[inFileName] = textureID;
    return textureID;
  }

这段函数就是根据纹理的名称,来打开纹理文件、给其赋予贴图编号、将其绑定在Model->textures上。knownTextures是为了防止重复绑定相同纹理的map记录。

Model* loadOBJ(const std::string& objFile)
    {
        Model* model = new Model;
        const std::string modelDir
            = objFile.substr(0, objFile.rfind('/') + 1);
...

        std::map<std::string, int>      knownTextures;
...
        for (int faceID=0;faceID<shape.mesh.material_ids.size();faceID++) {
          if (shape.mesh.material_ids[faceID] != materialID) continue;
          tinyobj::index_t idx0 = shape.mesh.indices[3*faceID+0];
          tinyobj::index_t idx1 = shape.mesh.indices[3*faceID+1];
          tinyobj::index_t idx2 = shape.mesh.indices[3*faceID+2];
          
          vec3i idx(addVertex(mesh, attributes, idx0, knownVertices),
                    addVertex(mesh, attributes, idx1, knownVertices),
                    addVertex(mesh, attributes, idx2, knownVertices));
          mesh->index.push_back(idx);
          mesh->diffuse = (const vec3f&)materials[materialID].diffuse;
          mesh->diffuseTextureID = loadTexture(model,
                                               knownTextures,
                                               materials[materialID].diffuse_texname,
                                               modelDir);
        }

回到LoadObj函数,我们就需要提前创建一个knownTextures;同时当我们在创建新的TriangleMesh的时候,调用LoadTextures来加载当前材质所需的贴图并获得该贴图编号,并将编号赋值给TriangleMesh的diffuseTextureID上。

因为这一example我们需要引入法线、uv,而且涉及贴图绑定的内容,因此需要在SampleRenderer.h中额外申请以下变量:

    std::vector<CUDABuffer> normalBuffer;
    std::vector<CUDABuffer> texcoordBuffer;
    std::vector<cudaArray_t>         textureArrays;
    std::vector<cudaTextureObject_t> textureObjects;

在渲染器进行构建的时候,我们需要在管线创建完成之后、建立SBT之前,将纹理全部注册:

void SampleRenderer::createTextures()
  {
    int numTextures = (int)model->textures.size();

    textureArrays.resize(numTextures);
    textureObjects.resize(numTextures);
    
    for (int textureID=0;textureID<numTextures;textureID++) {
      auto texture = model->textures[textureID];
      
      cudaResourceDesc res_desc = {};
      
      cudaChannelFormatDesc channel_desc;
      int32_t width  = texture->resolution.x;
      int32_t height = texture->resolution.y;
      int32_t numComponents = 4;
      int32_t pitch  = width*numComponents*sizeof(uint8_t);
      channel_desc = cudaCreateChannelDesc<uchar4>();
      
      cudaArray_t   &pixelArray = textureArrays[textureID];
      CUDA_CHECK(MallocArray(&pixelArray,
                             &channel_desc,
                             width,height));
      
      CUDA_CHECK(Memcpy2DToArray(pixelArray,
                                 /* offset */0,0,
                                 texture->pixel,
                                 pitch,pitch,height,
                                 cudaMemcpyHostToDevice));
      
      res_desc.resType          = cudaResourceTypeArray;
      res_desc.res.array.array  = pixelArray;
      
      cudaTextureDesc tex_desc     = {};
      tex_desc.addressMode[0]      = cudaAddressModeWrap;
      tex_desc.addressMode[1]      = cudaAddressModeWrap;
      tex_desc.filterMode          = cudaFilterModeLinear;
      tex_desc.readMode            = cudaReadModeNormalizedFloat;
      tex_desc.normalizedCoords    = 1;
      tex_desc.maxAnisotropy       = 1;
      tex_desc.maxMipmapLevelClamp = 99;
      tex_desc.minMipmapLevelClamp = 0;
      tex_desc.mipmapFilterMode    = cudaFilterModePoint;
      tex_desc.borderColor[0]      = 1.0f;
      tex_desc.sRGB                = 0;
      
      // Create texture object
      cudaTextureObject_t cuda_tex = 0;
      CUDA_CHECK(CreateTextureObject(&cuda_tex, &res_desc, &tex_desc, nullptr));
      textureObjects[textureID] = cuda_tex;
    }
  }

由于cpu和gpu储存、绑定纹理的形式是完全不同的,因此需要在gpu端对所有用到的纹理进行依次注册。上述代码主要是对所有纹理进行空间计算,并对纹理进行模式定义(滤波、格式等),然后将其绑定到GPU的通道中,最后的textureObjects就是可以被gpu所访问的纹理空间,这里装的纹理可以在Record中被绑定。要问具体每条api代表啥意思,我只能说太复杂了看不懂,略过。

接下来绑定一下SBT:

int numObjects = (int)model->meshes.size();
    std::vector<HitgroupRecord> hitgroupRecords;
    for (int meshID=0;meshID<numObjects;meshID++) {
      auto mesh = model->meshes[meshID];
      
      HitgroupRecord rec;
      // all meshes use the same code, so all same hit group
      OPTIX_CHECK(optixSbtRecordPackHeader(hitgroupPGs[0],&rec));
      rec.data.color   = mesh->diffuse;
      if (mesh->diffuseTextureID >= 0) {
        rec.data.hasTexture = true;
        rec.data.texture    = textureObjects[mesh->diffuseTextureID];
      } else {
        rec.data.hasTexture = false;
      }
      rec.data.index    = (vec3i*)indexBuffer[meshID].d_pointer();
      rec.data.vertex   = (vec3f*)vertexBuffer[meshID].d_pointer();
      rec.data.normal   = (vec3f*)normalBuffer[meshID].d_pointer();
      rec.data.texcoord = (vec2f*)texcoordBuffer[meshID].d_pointer();
      hitgroupRecords.push_back(rec);
    }
    hitgroupRecordsBuffer.alloc_and_upload(hitgroupRecords);
    sbt.hitgroupRecordBase          = hitgroupRecordsBuffer.d_pointer();
    sbt.hitgroupRecordStrideInBytes = sizeof(HitgroupRecord);
    sbt.hitgroupRecordCount         = (int)hitgroupRecords.size();

SBT部分就是多了一个纹理检测,对每个TriangleMesh检查diffuseTextureID,如果是-1代表没有纹理,就向Record中的hasTexture写入fase;如果≥0代表有纹理,将textureObjects中寄存的纹理赋值给Record中的texture。剩下就是要追加绑定一下normal和texcoord项,因为在shader中采样纹理需要用到各个顶点的texcoord。

最后一步,就是编写以下着色器代码。老样子,我们只需要改写ClosetHit Shader:

extern "C" __global__ void __closesthit__radiance()
  {
    const TriangleMeshSBTData &sbtData
      = *(const TriangleMeshSBTData*)optixGetSbtDataPointer();
    
    // ------------------------------------------------------------------
    // gather some basic hit information
    // ------------------------------------------------------------------
    const int   primID = optixGetPrimitiveIndex();
    const vec3i index  = sbtData.index[primID];
    const float u = optixGetTriangleBarycentrics().x;
    const float v = optixGetTriangleBarycentrics().y;

    // ------------------------------------------------------------------
    // compute normal, using either shading normal (if avail), or
    // geometry normal (fallback)
    // ------------------------------------------------------------------
    vec3f N;
    if (sbtData.normal) {
      N = (1.f-u-v) * sbtData.normal[index.x]
        +         u * sbtData.normal[index.y]
        +         v * sbtData.normal[index.z];
    } else {
      const vec3f &A     = sbtData.vertex[index.x];
      const vec3f &B     = sbtData.vertex[index.y];
      const vec3f &C     = sbtData.vertex[index.z];
      N                  = normalize(cross(B-A,C-A));
    }
    N = normalize(N);

    // ------------------------------------------------------------------
    // compute diffuse material color, including diffuse texture, if
    // available
    // ------------------------------------------------------------------
    vec3f diffuseColor = sbtData.color;
    if (sbtData.hasTexture && sbtData.texcoord) {
      const vec2f tc
        = (1.f-u-v) * sbtData.texcoord[index.x]
        +         u * sbtData.texcoord[index.y]
        +         v * sbtData.texcoord[index.z];
      
      vec4f fromTexture = tex2D<float4>(sbtData.texture,tc.x,tc.y);
      diffuseColor *= (vec3f)fromTexture;
    }
    
    // ------------------------------------------------------------------
    // perform some simple "NdotD" shading
    // ------------------------------------------------------------------
    const vec3f rayDir = optixGetWorldRayDirection();
    const float cosDN  = 0.2f + .8f*fabsf(dot(rayDir,N));
    vec3f &prd = *(vec3f*)getPRD<vec3f>();
    prd = cosDN * diffuseColor;
  }

我们来详细解析一下这段Shader。首先调用了optixGetTriangleBarycentrics()函数,得到了交点在当前三角形的重心坐标。什么是重心坐标呢?假如有一个三角形ABC,取三角形内部一点P,满足AP=u*AB+v*AC,则(u,v)就是P点在该三角形内的1重心坐标。当P点位于B点时,u=1,v=0;当P点位于C点时,u=0,v=1;当P点位于A点时,u=0,v=0。因此我们可以用u描述P离B有多近,用v描述P离C有多近,用1-u-v描述P离A有多近。

之所以要用到重心坐标,是因为我们希望进行Phong式着色,即平滑法线。因为三角形的三个顶点的法线往往指向不同的方向,因此三角形面内的点就需要对三者的法线进行插值。事实上,交点离哪个顶点更近,那么插值哪个顶点的权重就更高,而重心坐标刚好就可以描述这种权重。当然,如果三角形没有法线数据,那么老老实实做叉乘求法线就好。

对于纹理采样,我们需要先求出交点的texcoord,其实求交点texcoord的算法和法线一样,也是以重心坐标为权重 来计算texcoord的插值。得到texcoord的u、v值后,去Record->data中绑定的纹理进行采样,便得到了该交点的纹理颜色。

编译cuda---parse ptx---编译工程---运行项目,喜提runtime error: