1. 程式人生 > >PBRT筆記(9)——貼圖

PBRT筆記(9)——貼圖

映射 基礎 文件名 表達 進行 占用 間隔 問題 由於

采樣與抗鋸齒

當高分辨率貼圖被縮小時,貼圖會出現嚴重的混淆現象。雖然第7章中的非均勻采樣技術可以減少這種混疊的視覺影響,但是更好的解決方案是實現基於分辨率進行采樣的紋理函數。

可以在使用貼圖時先對貼圖的分辨率進行判斷,避免采樣高分辨率貼圖。
為了解決貼圖采樣函數造成的混淆問題,我們必須解決以下兩個問題:

  1. 必須計算貼圖空間的采樣率,以及獲得貼圖分辨率,之後就可以計算出屏幕空間的采樣率,最後為了獲得圖元表面的采樣率就必須對貼圖進行采樣,以獲得貼圖采樣率。
  2. 對於給定的貼圖采樣率,必須使用采樣理論去引導計算貼圖值,不能有高頻的貼圖。

尋找合適的圖片采樣率

考慮一個定義在場景圖元表面上任意位置的貼圖函數,其形參為圖元表面的位置信息,T(p)。在忽略可見性與遮擋的情況下,可以表達為這個函數在貼圖坐標(x,y)上,即T(f(x,y))。其中將貼圖坐標映射到圖元表面坐標。

有一個簡單的想法,將這個2D貼圖函數T(s,t),貼到一個正方體Z軸方向上,並與4個點(0,0,0)、(1,0,0)、(1,1,0)、(0,1,0)對齊。如此一來,\(s=P_x \quad t=P_y\),它與屏幕坐標的相對關系可以表達為(xr,yr為屏幕圖像的分辨率):\(s=\frac{x}{x_r} \quad t=\frac{y}{y_r}\),這裏就是說明圖元貼圖UV坐標、屏幕UV坐標以以及映射關系。對於復雜場景、相機位置以及貼圖坐標映射來說,獲取準確的相對關系比較困難(對應坐標映射到貼圖坐標)。對於抗鋸齒來說只需要找出像素采樣位置的變化與圖像上某一點紋理采樣位置的變化之間的關系。
這個關系可以使用一個近似的偏導數方程來表示:

\(f(x',y') \approx f(x,y)+(x'-x)\frac{\partial_f}{\partial_x}+(y'-y)\frac{\partial_f}{\partial_y}\)
如果在x‘-x與y‘-y方向上的變化率較小,以上的近似公式就是合理的。更重要的是用以上計算出的結果就可以直接算出貼圖采樣率。例如:在四邊形中,\(\partial_s/\partial_x=1/x_r \quad \partial_s / \partial_y =0 \quad \partial_t/\partial_x=0 \quad \partial_t /\partial_y=1/y_r\)

計算偏導數值的邏輯在RayDifferential類中。使用GenerateRayDifferential()進行初始化,它除了包含了當前位置的光線外,還有x,y方向分別偏移1個像素的兩條輔助光線,這兩條光線不參與幾何圖元的相交計算。

之後將計算各種偏導?p/?x、?p/?y、?u/?x、?v/?x、?u/?y、?v/?y。以上值會在ComputeDifferentials()中計算。圖10.3很好的展示了計算過程。

void SurfaceInteraction::ComputeDifferentials(
    const RayDifferential &ray) const {
    if (ray.hasDifferentials) {
        //計算微分偏移光線與平面的交點px與py,之後計算微分
        Float d = Dot(n, Vector3f(p.x, p.y, p.z));
        Float tx =
            -(Dot(n, Vector3f(ray.rxOrigin)) - d) / Dot(n, ray.rxDirection);
        if (std::isinf(tx) || std::isnan(tx)) goto fail;
        Point3f px = ray.rxOrigin + tx * ray.rxDirection;
        Float ty =
            -(Dot(n, Vector3f(ray.ryOrigin)) - d) / Dot(n, ray.ryDirection);
        if (std::isinf(ty) || std::isnan(ty)) goto fail;
        Point3f py = ray.ryOrigin + ty * ray.ryDirection;
        dpdx = px - p;
        dpdy = py - p;

        int dim[2];
        if (std::abs(n.x) > std::abs(n.y) && std::abs(n.x) > std::abs(n.z)) {
            dim[0] = 1;
            dim[1] = 2;
        } else if (std::abs(n.y) > std::abs(n.z)) {
            dim[0] = 0;
            dim[1] = 2;
        } else {
            dim[0] = 0;
            dim[1] = 1;
        }

        Float A[2][2] = {{dpdu[dim[0]], dpdv[dim[0]]},
                         {dpdu[dim[1]], dpdv[dim[1]]}};
        Float Bx[2] = {px[dim[0]] - p[dim[0]], px[dim[1]] - p[dim[1]]};
        Float By[2] = {py[dim[0]] - p[dim[0]], py[dim[1]] - p[dim[1]]};
        if (!SolveLinearSystem2x2(A, Bx, &dudx, &dvdx)) dudx = dvdx = 0;
        if (!SolveLinearSystem2x2(A, By, &dudy, &dvdy)) dudy = dvdy = 0;
    } else {
    fail:
        dudx = dvdx = 0;
        dudy = dvdy = 0;
        dpdx = dpdy = Vector3f(0, 0, 0);
    }
}

貼圖過濾函數

對於貼圖采樣率,有必要去除超過紋理采樣率Nyquist極限的頻率。這時候就需要使用過濾器了,當然本人沒《信號與系統》基礎,這裏直接跳了。

反射與透射的光線微分

為了計算圖元表面交點處的反射或透射。我們需要兩條經過偏移的微分光線。尋找這些光線需要一些技巧。Igehy(1999)觀察到,比起通過x、y方向上的偏移來計算微分光線,如果已知反射方向ωi相對於貼圖像素采樣上(x、y方向)的變化率,則可以進行以下近似計算:

\(\omega=\omega_i+\frac{\partial_{\omega_i}}{\partial_x}\)

將反射公式偏導化可得:

\(\frac{\partial_{\omega_i}}{\partial_x}=\frac{\partial}{\partial_x}(-\omega_o+2(\omega_o \cdot n)n)=-\frac{\partial_{\omega_o}}{\partial_x}+2((\omega_o \cdot n)\frac{\partial_n}{\partial_x}+\frac{\partial_{(\omega_o \cdot n)}}{\partial_x}n)\)

利用點乘性質可得:
\(\frac{\partial_{\omega_o \cdot n}}{\partial_x}=\frac{\partial_{\omega_o}}{\partial_x}\cdot n+\omega_o \cdot \frac{\partial_n}{\partial_x}\)
以下代碼在:SamplerIntegrator::SpecularReflect中

//計算反射微分光線的方向
RayDifferential rd = isect.SpawnRay(wi);
    if (ray.hasDifferentials) {
        rd.hasDifferentials = true;
        rd.rxOrigin = isect.p + isect.dpdx;
        rd.ryOrigin = isect.p + isect.dpdy;
        //計算x、y分量上的方向
        Normal3f dndx = isect.shading.dndu * isect.dudx +
                        isect.shading.dndv * isect.dvdx;
        Normal3f dndy = isect.shading.dndu * isect.dudy +
                        isect.shading.dndv * isect.dvdy;
        Vector3f dwodx = -ray.rxDirection - wo,
                 dwody = -ray.ryDirection - wo;
        Float dDNdx = Dot(dwodx, ns) + Dot(wo, dndx);
        Float dDNdy = Dot(dwody, ns) + Dot(wo, dndy);
        rd.rxDirection =wi - dwodx + 2.f * Vector3f(Dot(wo, ns) * dndx + dDNdx * ns);
        rd.ryDirection =wi - dwody + 2.f * Vector3f(Dot(wo, ns) * dndy + dDNdy * ns);
    }
    return f * Li(rd, scene, sampler, arena, depth + 1) * AbsDot(wi, ns) /pdf;

貼圖類與接口

  1. 基類為Texture,唯一接口Evaluate(),根據圖元表面位置獲取對應貼圖坐標的值。
  2. 子類ConstantTexture,位於constant.h中。返回相同值,因為這個特性,所以他不需要抗鋸齒。
  3. 子類ScaleTexture,位於scale.h中。返回兩個貼圖值的乘機。
  4. 子類MixTexture,位於mix.h中。使用一個alpha貼圖值,返回兩個貼圖值的線性插值。
  5. 子類BilerpTexture,位於bilerp.h中,設定四個貼圖坐標,返回以此計算的雙線性插值。
ImageTexture

子類ImageTexture,位於imagemap.h中,以二位數組的方式存儲貼圖函數的采樣點(將像素圖的每一個像素值當成一個采樣點),之後再利用這些采樣點還原這個貼圖函數。

與上述子類不同,它存儲與返回的數據都是參數化的。除了貼圖文件名外,還有伽馬矯正、mipmap、過濾器(抗鋸齒用)等參數。這些都被存儲在TexInfo結構體中。

貼圖內存管理

PBRT維護了一個map用來管理貼圖資源,避免資源被多次加載的情況。

使用GetTexture()來獲取貼圖的Mipmap

    TexInfo texInfo(filename, doTrilinear, maxAniso, wrap, scale, gamma);
    if (textures.find(texInfo) != textures.end())
        return textures[texInfo].get();
    /*
    中間略
    */
    std::unique_ptr<RGBSpectrum[]> texels = ReadImage(filename, &resolution);
    /*
    中間略
    */
    MIPMap<Tmemory> *mipmap = nullptr;
    if (texels) {
        //readmage返回的是RGBSpectrum類型,所以就必須將其轉化為mipmap的類型Tmemory
        std::unique_ptr<Tmemory[]> convertedTexels(
            new Tmemory[resolution.x * resolution.y]);
        for (int i = 0; i < resolution.x * resolution.y; ++i)
            convertIn(texels[i], &convertedTexels[i], scale, gamma);
        mipmap = new MIPMap<Tmemory>(resolution, convertedTexels.get(),
                                     doTrilinear, maxAniso, wrap);
    } else {
        // Create one-valued _MIPMap_
        Tmemory oneVal = scale;
        mipmap = new MIPMap<Tmemory>(Point2i(1, 1), &oneVal);
    }
    textures[texInfo].reset(mipmap);
    return mipmap;

convertIn函數中還涉及到了縮放以及gamma矯正,以便將像素值映射到指定範圍。pbrt遵循sRGB標準,該標準規定了一條特定的曲線來匹配CRT顯示器的顯示。sRGB gamma曲線是一個分段函數,其低值為線性項,其大中型值為冪項。
\(\gamma= \left\{ \begin{array}{cc} 12.92x, & x\leq 0.0031308\\ 0, & x >0.0031308 \end{array} \right.\)

inline Float GammaCorrect(Float value) {
    if (value <= 0.0031308f) return 12.92f * value;
    return 1.055f * std::pow(value, (Float)(1.f / 2.4f)) - 0.055f;
}

這個函數被用在WriteImage()函數中,用於寫入8位sRGB圖片數據。當然convertIn中用的是與之相反的操作:

inline Float InverseGammaCorrect(Float value) {
    if (value <= 0.04045f) return value * 1.f / 12.92f;
    return std::pow((value + 0.055f) * 1.f / 1.055f, (Float)2.4f);
}

如果圖片渲染完成,會執行ClearCache(),將之前維護資源用的map清空。

mipmap

之前有說高分辨率圖像顯示在較小的區域中時會出現圖像混疊現象,如果使用給定點,以及使用貼圖空間采樣率進行估算得到結果。這樣做消耗比較高。而為了滿足Nyquist標準,我們必須去除至少高於相鄰采樣距離兩倍的頻率。

貼圖采樣與重建不同,它對性能有著一定要求,同時其采樣率也是不確定的,它會隨著場景多邊形、貼圖坐標映射函數、攝像機投影等因素的變化而變化。

PBRT使用了兩種方法實現mipmap,第一種是三線性插值法,快速而且容易實現,在早期顯卡中廣泛使用。第二種是橢圓加權平均算法,速度慢且復雜,但是效果好。

上述方法均采用了圖像金字塔結構(生成分辨率從小到大的金字塔結構),與原始圖片相比會多占用1/3空間。使用mipmap得確保圖像分辨率為\(2^n\)

ImageWrap枚舉的作用是:當提供的貼圖坐標不在合法的[0,1]範圍中時,傳遞給MIPMap構造函數的指定行為。

如果用於給予的貼圖分辨率不是2的冪,PBRT則會講圖片分辨率調整為下一個2的冪。這裏涉及到圖片縮放涉及到采樣與重建理論。我們想要從原始樣本中重采樣(新的采樣位置),從而重建一個連續的圖像函數。因為采樣率提升了,所以我們不必擔心因為采樣不足而造成的圖像混疊問題,我們只需要重新采樣並且重建圖像函數。

MipMap使用一個可分離的重構過濾器來完成這個任務。可分離過濾器可以寫成以為1維過濾器的乘積f(x,y)=f(x)f(y)。實現重采樣可以分為兩個一維重采樣:第一步:重采樣s完成(s‘,t)分辨率的圖片,第二步重采樣t完成(s‘,t‘)分辨率 圖片。(s,t)=>(s‘,t‘) 這樣可以大大減少計算復雜度。

resampleWeights()方法確定所有原始像素對新的像素的貢獻值權重值。它返回一個ResampleWeight結構體數組。這裏的重構器會計算4個原始像素的貢獻權重,因為4個像素緊挨在一起,所以只需要一個偏移值和一個權重數組。(以上內容都在構造函數中)

struct ResampleWeight {
    int firstTexel;
    Float weight[4];
};
std::unique_ptr<ResampleWeight[]> resampleWeights(int oldRes, int newRes) {
    CHECK_GE(newRes, oldRes);
    std::unique_ptr<ResampleWeight[]> wt(new ResampleWeight[newRes]);
    Float filterwidth = 2.f;
    for (int i = 0; i < newRes; ++i) {
        
        Float center = (i + .5f) * oldRes / newRes;
        wt[i].firstTexel = std::floor((center - filterwidth) + 0.5f);
        for (int j = 0; j < 4; ++j) {
            Float pos = wt[i].firstTexel + j + .5f;
            wt[i].weight[j] = Lanczos((pos - center) / filterwidth);
        }
        //規整化操作保證了圖像亮度統一
        Float invSumWts = 1 / (wt[i].weight[0] + wt[i].weight[1] +
                               wt[i].weight[2] + wt[i].weight[3]);
        for (int j = 0; j < 4; ++j) wt[i].weight[j] *= invSumWts;
    }
    return wt;
}

獲取了權重後,就會根據ImageWrap參數,進行計算,需要計算s與t方向。

由於儲存圖像使用了大量內存,且每次圖像像素查找濾波值都需要讀取8~20個像素,為了提升性能PBRT在這裏使用了BlockedArray,具體詳見附錄A。

//存儲了mipmap的圖像金字塔結構
std::vector<std::unique_ptr<BlockedArray<T>>> pyramid;

第一層為原始圖像(如果分辨率不為2的冪,則會存入重采樣的圖像)。
在展示如何初始化其余級別之前,我們先定義一個texel訪問函數:MIPMap::Texel()返回給定離散整數值Texel位置的Texel值的引用。對於超出範圍的則會根據wrapMode返回對應的值。

const T &MIPMap<T>::Texel(int level, int s, int t) const {
    CHECK_LT(level, pyramid.size());
    const BlockedArray<T> &l = *pyramid[level];
    switch (wrapMode) {
    case ImageWrap::Repeat:
        s = Mod(s, l.uSize());
        t = Mod(t, l.vSize());
        break;
    case ImageWrap::Clamp:
        s = Clamp(s, 0, l.uSize() - 1);
        t = Clamp(t, 0, l.vSize() - 1);
        break;
    case ImageWrap::Black: {
        static const T black = 0.f;
        if (s < 0 || s >= (int)l.uSize() || t < 0 || t >= (int)l.vSize())
            return black;
        break;
    }
    }
    return l(s, t);
}

最後使用盒式過濾器,生成所有級別的mipmap。這裏使用Lanczos過濾器會得到更好的結果。

各項同性三角形過濾器

兩個MIPMap::Lookup()方法中第一個方法是返回使用三角形濾波器移除高頻信息後的圖像,雖然無法生成高質量的結果,但速度比較快。該濾波器因為各項同性的關系不支持非正方形或非軸對稱的範圍。該濾波器的主要缺點是:在斜角度觀察紋理時圖像容易變模糊。因為不同的角度會導致采樣率不一致。

像素間隔寬度為:\(\frac{1}{w}=2^{nLevels-1-l}\)

//那麽可以求出l為:
Float level = Levels() - 1 + Log2(std::max(width, (Float)1e-8));
if (level < 0)
    return triangle(0, st);
else if (level >= Levels() - 1)
    return Texel(Levels() - 1, 0, 0);
else {
    //通過插值計算來實現不同mipmap級別過度效果
    int iLevel = std::floor(level);
    Float delta = level - iLevel;
    return Lerp(delta, triangle(iLevel, st), triangle(iLevel + 1, st));
}

為了計算圖像紋理函數在任意(s,t)位置的值,MIPMAP::triangle尋找(s,t)周圍四個最近的像素點,其權重為各自與(s,t)的距離。第一步:使用下面的兩個像素點插值得到(s,t0),使用上面的兩個像素插值得到(s,t1)。第二步:將上面得到的兩個值進行插值計算得到(s,t)。(雙線性插值計算)

MIPMap::triangle(int level, const Point2f &st) const {
    level = Clamp(level, 0, Levels() - 1);
    Float s = st[0] * pyramid[level]->uSize() - 0.5f;
    Float t = st[1] * pyramid[level]->vSize() - 0.5f;
    int s0 = std::floor(s), t0 = std::floor(t);
    Float ds = s - s0, dt = t - t0;
    return (1 - ds) * (1 - dt) * Texel(level, s0, t0) +
           (1 - ds) * dt * Texel(level, s0, t0 + 1) +
           ds * (1 - dt) * Texel(level, s0 + 1, t0) +
           ds * dt * Texel(level, s0 + 1, t0 + 1);
}

PBRT筆記(9)——貼圖