博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

How to write a simple software rasterizer

Posted on 2013-03-16 00:14  hustruan  阅读(5098)  评论(4编辑  收藏  举报

How to write a simple software rasterizer

 

 

 

Why to write a software rasterizer

众所周知,已经存在硬件加速的光栅化渲染器,如OpenGL, Direct3D。一些专业书如红宝书《OpenGL编程宝典》也介绍了如何使用OpenGL的渲染管线。但自己写软件光栅化渲染器,不仅可以增加对渲染管线的理解,还能掌握一些书上没讲到的知识,一些有OpenGL编程经验的人可能也不知道的,比如Perspective-Correct Interpolation等。这次要不是做图形学课程的大作业,也不知道什么时候我会去实现个这东西。

Features of our software rasterizer

主要模拟一下整个渲染管线,主要包括 Vertex shading, Primitive Assembly, Perspective transformation, Rasterization, Fragment shading等,这些是最基本的,做完这些至少能简单的渲染一张Image出来了。现在写software rasterizer没有必要再写个类似OpenGL的fixed pipeline,直接写个programmalbe pipeline反正更加简单。利用面向对象语言里的继承、多态,完全可以让用户自己写各种Shader代码。当然还可以有更高级的做法,如空明流转的SALVIA,完全可以自己首先整个Shader编译器,定义一套类似GLSL, HLSL的shader语言。反正目前我是没那能耐去折腾编译器,所以就实现了个把shader写死在C++代码里的版本。

How to write

1. Vertex Process

Vertex shading就不多说了,写过shader的都知道干嘛的。既然要实现一个programmable pipeline,那么至少也得知道shader写在哪,怎么写。不像GLSL、HLSL,Shader代码写个单独的文件里。最简单的方法就是利用C++的继承和多态,定义一套shader的基类,以后要写新的shader时,继承一下就好了。这种方法的缺点就是Shader被写死在C++里了,当然你也可以用Shader做成dll,动态导入。

Base Class:

class VertexShader : public Shader
{
public:
    VertexShader();
    virtual ~VertexShader();

    virtual void Execute(const VS_Input* input, VS_Output* output) = 0;
};

class PixelShader : public Shader
{
public:
    PixelShader();
    virtual ~PixelShader();

    /**
     * return false if discard current pixel
     */
    virtual bool Execute(const VS_Output* input, PS_Output* output, float* pDepthIO) = 0;
};

 

Derived Class

class SimpleVertexShader : public VertexShader
{
public:

    void Bind()
    {
        DeclareVarying(InterpolationModifier::Linear, float4, oPosW, 0);
        DeclareVarying(InterpolationModifier::Linear, float4, oNormal, 1);
        DeclareVarying(InterpolationModifier::Linear, float2, oTex, 2);
    }

    void Execute(const VS_Input* input, VS_Output* output)
    {
        DefineAttribute(float4, iPos, 0);
        DefineAttribute(float4, iNormal, 1);
        DefineAttribute(float2, iTex, 2);

        DefineVaryingOutput(float4, oPosW, 0);
        DefineVaryingOutput(float4, oNormal, 1);
        DefineVaryingOutput(float2, oTex, 2);

        oPosW = iPos * World;
        oNormal = float4(iNormal.X(), iNormal.Y(), iNormal.Z(), 0.0) * World;
        oTex = iTex;

        output->Position = oPosW * View * Projection;
    }

    uint32_t GetOutputCount() const
    {
        return 4;
    }

public:
    float44 World;
    float44 View;
    float44 Projection;
};

 上面只是个简单的示例,可以看到Shader参数现在可以直接简单地定义为成员变量,使用起来相当方便。

2 Primitive Assembly

Vertex Process相当于做了顶点变换,以及保存一些varying变量,插值后给Fragment Shader使用。Vertex Shader输出的顶点定义在Clip Space,我们可以一次性把View Frustum的上下左右前后6个面都做Clip,也可以只做**面和远*面,后面扫描线的时候,还可以在屏幕空间裁减。根据《3D游戏编程大师技巧》的说法,后面屏幕空间的裁剪可能速度更快,所以我的实现也只裁剪了*、远*面。但是,**面是一定要Clip的,原因看这篇文章,http://www.altdevblogaday.com/2012/04/14/software-rasterizer-part-1/,可能需要FQ。裁剪完了做perspective divide,之后便是viewpot transform。这里很重要的一点就是perspective correct interpolation。我不介绍,大致看这篇文章吧,http://www.cnblogs.com/ArenAK/archive/2008/03/13/1103532.html。所以要把1/z给保存下来,所有的Vertex Shader输出,varying变量都要乘上1/z。但是经过Vertex Shader后,顶点已经在Clip Space,怎么获得Z值呢,这个根据Projection矩阵自己倒推一下就可以了。Primitive Assembly就是把一个个顶点组成三角形光栅化。

3 Rasterization

光栅化这里可大有文章,光栅化主要干的事就是确定哪些像素块属于三角形内部。这里有两点要先介绍一下,Top-Left填充规则和顶点属性的插值计算方法。Top-Left填充规则大致看这篇文章,http://blog.csdn.net/damenhanter/article/details/6388934,讲得比较详细。顶点属性的插值,主要是利用三角形Barycentric coordinate,参考《Fundamentals of Computer Graphics》第三版,2.7节。可以自己推公式,大致就是选择三个顶点中的一个点作为base点,计算沿着两条边的Difference,写成ddx, ddy的形式,插值时,只要计算相对于base点的offsetX, offsetY,根据ddx, ddy就能计算插值后的值。关于BarryCentric Coordinate插值,还可以参考这篇Gamedev的文章,http://www.gamedev.net/topic/457998-software-rendering---vertex-attribute-interpolation/

光栅化主要介绍两种方法,经典的扫描线Scanline算法和Tiled-based的算法。扫描线算法比较经典,就是把一个三角形分成*底三角形和*顶三角形,按行扫描就好了,可以参考《3D游戏编程大师技巧》第九章。Tiled-based的算法参考Advanced Rasterization。我不多做介绍,这两篇文章都介绍的很详细了。另外在提供两篇文章,Rasterization on LarrabeeA modern approach to software rasterization。前面一篇是Intel Larrabee架构下的一个Rasterizer,空明流转的SALVIA好像就是用的这个算法。后面一篇我觉得也不错,实现起来也相对简单。另外还有一篇GPU上cuda实现的,我觉得应该是最高效的方法,可以参考论文“High-Performance Software Rasterization on GPUs”。扫描线算法主要不太适合多线程,主要是后面Fragment Shading的时候,肯定会有好几个线程同时更新同一个像素backbuffer的问题,但gameKnife大神的多线程版扫描线效率很高,我也不知道他具体是怎么做的。而Tiled-based的算法则对多线程比较友好,不过我简单的实现了一个Tiled-based的half-space算法,好像也没快到哪里去。主要感觉虽然多线程了,但每个CPU core的使用率不是很高,可能Cahce没用好。第一次写多线程的程序,没什么经验。

4 Fragment Shading

Fragmene Shader也可以模仿上面Vertex Shader的方法,通过继承实现。

class SimplePixelShader : public PixelShader
{
public:

    float3 LightPos;

    DefineTexture(0, DiffuseTex);
    DefineSampler(0, LinearSampler);

    bool Execute(const VS_Output* input, PS_Output* output, float* pDepthIO)
    {
        DefineVaryingInput(float3, iPosW, 0);
        DefineVaryingInput(float3, iNormal, 1);
        DefineVaryingInput(float2, iTex, 2);

        float3 L = Normalize(LightPos - iPosW);
        float3 N = Normalize(iNormal);
        float NdotL = Dot(N, L);

        ColorRGBA diffuse = Sample(DiffuseTex, LinearSampler, iTex.X(), iTex.Y());

        output->Color[0] = Saturate(diffuse * NdotL);

        return true;
    }

    uint32_t GetOutputCount() const
    {
        return 1;
    }
};

 跑Fragment Shader前,先对当前像素用插值方法计算Vertex Shader过来的顶点属性,插值的方法上面介绍了,计算挺方便的。Fragment Shader这块最大的问题是,很难计算屏幕空间的导数,这个在做mipmap的时候需要用到。具体可以参考空明流转的《开源光栅化渲染器SALVIA的漫长五年(准·干货)》。我觉得要做这个的话,就应该像空明流转说的,用硬件的方式来执行,一次让2x2的像素块一起执行,所以如果还是使用用C++写shader的话,肯定不能再像上面一样,一个Execute函数,让一个fragment执行,必须要四个像素一起执行。这方面可以参考A modern approach to software rasterization的shader实现。Fragment Shader完了后,就是Z-Test,Blend等了,这个应该比较好写,完全可以自己代码控制。

另外再附加几个链接吧,可以参考一下

A trip through the Graphics Pipeline 2011 http://fgiesen.wordpress.com/2011/07/09/a-trip-through-the-graphics-pipeline-2011-index/ 这个系列的文章相当的高级,介绍了Direct3D 11管线的各个方面,真的值得一读。

Perspective Texture Mapping http://chrishecker.com/Miscellaneous_Technical_Articles

 

最后贴一下我写的光栅化渲染器的效果图