65.9K
CodeProject 正在变化。 阅读更多。
Home

NormalMapCompressor——一个自动压缩法线贴图的工具

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2005年7月4日

15分钟阅读

viewsIcon

123264

downloadIcon

1413

法线贴图用于实时3D渲染(主要用于游戏)以提高视觉质量,但压缩它们会使3D内容看起来很丑,这个工具可以帮助解决这个问题。

注意:如果您在安装了DirectX 9的情况下仍然无法启动NormalMapCompressor,并收到“Microsoft.DirectX.dll未找到”的错误,那么您很可能没有安装Managed DirectX(或者出于某种原因,您的Managed DirectX无法与NormalMapCompressor一起启动)。要下载最新版本的DirectX,请访问Microsoft的DirectX网站。您可能需要使用“dxsetup.exe /InstallManagedDX”安装DirectX(抱歉,无法将那个32MB的文件包含在安装程序中)。给您带来不便,敬请谅解,在某些计算机上可以立即工作,在其他计算机上则不行。

目录

引言

NormalMapCompressor是一个用于自动压缩法线贴图的有用工具。如果您不知道什么是法线贴图或者这篇文章是关于什么的,您可能想跳过这篇文章,它相当具体。基本上,法线贴图用于实时3D渲染(主要用于游戏),通过在平面上产生额外的3D细节的印象来提高视觉质量。然而,法线贴图非常大,而且以今天1024x1024以上的纹理尺寸,您的加载时间会变得非常长,图形内存会很快被填满(并且每帧交换大量数据并不是一个FPS助推器)。无论如何,您都需要压缩颜色图像、法线贴图以及其他数据,如果您在游戏中有大量视觉内容。这个工具使用DirectX 9(在.NET中)来完成这项工作,并假定法线贴图存储在默认的xyz格式(Nvidia/Doom3风格,与ATI格式中的y反转相反)。然而,所有法线贴图格式都应该与此工具很好地工作(它不关心),但您可能需要替换fx着色器文件进行渲染。

本文档面向具有DirectX、.NET和法线贴图基础知识的经验丰富的读者。如果您是初学者,您可能会学到一些东西,但我猜如果您不知道基础知识,所有这些东西都会太令人困惑。这也不是一篇关于使用Managed DirectX渲染法线贴图的文章,但您可以使用我的源代码来了解它是如何工作的(我已经尽力注释了)。还有一些技巧可以使用像素着色器1.1实现镜面法线贴图,以及切换红色和alpha通道,但本文档的重点是压缩以及为获得最佳视觉质量和最小文件尺寸而使用的各种技巧(我读到Gf7800有一个特殊的法线贴图压缩格式,但这对于我们当前一代的显卡没有帮助)。

安装NormalMapCompressor后,您应该会看到以下屏幕(抱歉,我的Windows主题是疯狂的蓝色^^)

在左上角,您可以选择一个输入法线贴图和一个可选的高度图(用于视差或偏移映射,或您想存储在额外alpha通道中的任何内容)。下面您将看到有关当前加载文件的信息。重要信息是红色/绿色/蓝色变化百分比,如您所见,它们通常平均分布,或者红色/绿色分量变化更大。不好的是,DirectX用于颜色图像的DXT压缩公式只偏向于绿色通道(通常是30%、59%、11%的公式),蓝色/z通道不是那么重要(特别是如果我们稍后重新规范化),但通过更好地保留红色通道可以提高图像质量。好消息是DXT5格式提供了一个特殊的alpha通道,具有更好的压缩效果,如果我们只是交换红色和alpha通道,就可以使用它。这并非一项新技术,Doom3也使用了这种技术,并且ATI和Nvidia都有几篇文章发表了关于这些内容。请查看以下网站

但我找不到任何有用的工具来使用这项技术,而且图形人员自己转换法线贴图很麻烦,动态转换也不是一个好选择(谁想等那么久才能加载并转换所有纹理以进行简单的测试运行)。出于这个原因,我写了这个小程序,让您可以玩转您的法线贴图并自动化转换过程(参见批量转换)。

在右侧,您可以指定输出参数,例如生成mipmap、在压缩前规范化所有值以及压缩格式。如您所见,DXT1将提供最小的文件大小,但法线贴图质量很差,特别是当您的法线贴图有很多曲线时。DXT5 r-a交换格式是大多数法线贴图的相当不错的选择,它具有较小的文件大小(rgba的1/4,rgb的1/3),良好的压缩和不错的法线贴图质量。您现在唯一需要做的就是在像素着色器中交换r和a通道。

比较

你不相信我?好吧,我创建了一个测试法线贴图来检查不同压缩格式之间的差异。如您所见,DXT1压缩非常差(我甚至省略了测试其他压缩格式,如16位565 RGB甚至8位格式,这不值得,请参阅ATI/Nvidia的相应文章),DXT5 r-a交换模式非常不错,甚至可以通过像素着色器中的重新规范化来获得更好的视觉质量。

特点

嗯,NormalMapCompressor只是一个方便的小工具,用于测试压缩格式和转换单个文件,但通常您的图形艺术家会制作大量文件,您可能想在文件创建时就进行转换。因此,可以使用“批量转换”按钮,该按钮将打开以下对话框。

您可以选择一个输入目录、一个输入过滤器、一个可选的用于alpha通道过滤器的what map以及压缩格式,就像在前面的屏幕中一样(顺便说一句:规范化和生成mipmap设置从主屏幕获取)。现在只需选择一个输出目录(或将其与输入目录相同)然后按“开始”一次性转换所有法线贴图。

因为图形艺术家和我们程序员一样懒(特别是当您为他们提供大量有用工具时),并且您可能不知道哪些文件刚刚被更改,哪些文件没有更改,您可以通过使用“自动更新”模式来自动化检查过程。只需让程序运行,也许将其隐藏在托盘中,然后让它为您压缩所有(未压缩的)法线贴图。我们为图形艺术家的未压缩原始文件(包含所有PSD、BMP、TGA等文件)和供游戏快速加载纹理的目录提供了单独的目录。如果您需要更多压缩选项或更多的输入过滤器选项,只需更改BatchConvert.cs文件。

以下是处理法线贴图时一些有用的工具列表

  • 3DS Max and Maya 用于3D内容创作(不要问我细节^^)。
  • ZBrush 是一个快速创建和绘制凹凸的绝佳工具,您甚至可以用它制作2D法线贴图,但法线贴图材质使用Ati-Inverted-Y版本,使用此图像(链接)作为法线贴图材质(NormalRGBMat.zmt)来修复此行为并生成正确的XYZ法线贴图:)
  • Photoshop,一如既往,是图像处理、特效等的工具。Nvidia制作了两个很棒的插件:DDS Exporter和法线贴图滤镜,在此处下载。
  • 我仍然认为FX Composer是测试着色器、查看纹理效果和测试新图形功能的最佳程序。GPU Gems I和II也是关于高级着色器的很棒的书籍。

项目中的文件

项目中的所有数据文件概述

NormalMapCompressor.exe 这是好东西,运行它并感到快乐。
DirectionalNormalMapping.fx 所有着色器技术都在这里,您也可以将此文件用于3DStudio Max或Fx Composer,只需更改前几行的注释。
Microsoft.DirectX.* 来自2005年2月Managed DirectX安装的文件,让生活更轻松(安装它们很麻烦)。
DiffuseMap.dds 渲染球体所需的纹理,这是基础颜色纹理。
NormalizeCubeMap.dds 此文件是PS1.1镜面法线贴图着色器所必需的。
doorNormal.bmp 此文件来自Nvidia的凹凸压缩文章。
rockNormal.bmp 程序启动时的默认法线贴图。
stoneNormal.jpg 另一张石头纹理,表明即使使用良好的JPEG压缩,法线也丢失了一点(取消选中“规范化法线”以查看效果)。
testNormal.bmp 较小的测试法线贴图,您可以在文本的尖锐边缘看到压缩错误。小的法线贴图适合测试压缩。

项目中的所有源文件概述

Program.cs 应用程序的入口点,捕获所有程序集加载错误。
MainForm.cs 这是用于显示所有输入和输出选项并渲染3D球形的主要窗体。
BatchConvert.cs BatchConvert对话框的窗体,支持最小化到托盘。
BitmapHelper.cs 位图辅助方法,如规范化法线、交换r和a通道以及合并rgb和alpha位图。
TextureHelper.cs 帮助转换加载纹理的纹理格式。
MeshHelper.cs 辅助方法,为着色器提供兼容的顶点。
TangentVertex.cs 切线顶点用于所有着色器技术,并包含以下内容:位置、法线向量、纹理坐标、切线向量。
StringHelper.cs 字符串操作方法,使生活更轻松。
App.ico 应用程序图标,16x16和32x32,在3DStudio中渲染...
AssemblyInfo.cs 应用程序的程序集信息(v1.0)。

技巧和窍门

首先,我们必须启动应用程序,这对于Managed DirectX(以下简称MDX)应用程序来说并不那么愉快,因为仅加载MDX DLL就可能出现很多问题。如果您查看Program.cs,您可以看到以下代码安全地执行Main()中的代码,而无需预加载任何程序集(这将在StartApplication()中发生)。如果出现任何问题,我们仍然可以捕获错误并将其呈现给用户,而不是在没有错误消息的情况下崩溃。

    [STAThread]
    static void Main()
    {
        try
        {
            Application.EnableVisualStyles();
            StartApplication();
        } // try
        catch (System.IO.FileNotFoundException ex)
        {
            string filename = ex.FileName.Split(new char[] { ',' })[0];
            if (filename.EndsWith(".dll") == false &&
                filename.EndsWith(".exe") == false)
                filename += ".dll";
            MessageBox.Show("Important file not found (" + filename + "), 
                            unable to execute.\nError: " + ex.ToString(), 
                            "Fatal Error");
        } // catch (ex)
        catch (Exception ex)
        {
            MessageBox.Show("Fatal application error: " + 
                              ex.ToString(), "Fatal error");
        } // catch (ex)
    } // Main()
    
    /// <summary>
    /// Extra function to init context of 
    /// MainForm, this will throw
    /// an exception if something could not be loaded yet.
    /// </summary>
    static void StartApplication()
    {
        Application.Run(new MainForm());
    } // StartApplication()

安装程序将负责.NET和DirectX,并确保至少安装了.NET 1.1和DirectX 9,即使我们检查Managed DirectX,也有太多不兼容的版本,所以这不是一件轻松的事情。我在一些计算机上测试过,大多数非开发人员机器甚至没有安装MDX部分,如果安装了,很可能是另一个版本而不是我的。在花费一个小时安装了所有类型的DirectX版本并尝试找到一种通用的或简便的方法来安装MDX(猜猜看,没有简便的方法,MS真的不希望任何人拥有MDX)之后,我只是将所需的DLL文件复制到安装目录,并在我所有的测试机器上进行了测试,无论在哪里都能正常工作(但我听说有些人仍然有这些问题):)不知道这是否是一个“好”的解决方案(它只增加了安装程序300KB),但目前有效。MDX 2005年2月是最后一个带有Managed DirectX安装程序的版本,也许这可以帮助您克服您的麻烦:Feb2005_MDX

您可以在这里阅读更多关于这些内容

好的,在MainForm.cs中,我们将创建窗体并按照以下步骤初始化DirectX,参见第760-970行。

  • 设置DirectX设备(使用pictureBoxOutput作为目标控件)。
  • 检查像素着色器版本(ps1.1或ps2.0)。
  • 创建视图和投影矩阵,并设置所有光照和材质颜色。
  • 启动渲染计时器(间隔10毫秒)。
  • 创建3D球体进行渲染。
  • 加载所有辅助纹理(漫射贴图、ps1.1的法线立方体贴图)。
  • 最后,加载着色器效果(fx)文件以进行所有着色器业务。
  • 加载默认法线贴图后,开始渲染。

所有按钮、复选框和单选按钮都使用非常简单的代码,MainForm.cs中唯一的其他重要部分是渲染循环。

    // Clear background and start rendering
    device.Clear(ClearFlags.Target | ClearFlags.ZBuffer, 0, 1, 0);
    try
    {
        device.BeginScene();

        // If mouse is pressed, hold rotation!
        if (leftMouseButtonPressed == false)
            rotation += (Environment.TickCount-lastTick) / 2500.0f;
        lastTick = Environment.TickCount;

        // Init world matrix, just rotate around the y axis.
        worldMatrix = Matrix.RotationAxis(
            new Vector3(0, 1, 0), rotation)*
            Matrix.Scaling(zoomFactor, zoomFactor, zoomFactor);

        // Only use shader if right mouse button is not pressed
        if (rightMouseButtonPressed == false)
        {
            // Select technique depending on the selected shader technique
            // and if we use agbr dxt5 or not.
            effect.Technique =
                radioButtonDXT5Switched.Checked ?
                (comboBoxShaderTechnique.SelectedIndex == 0 ? "SpecularAgbr" :
                comboBoxShaderTechnique.SelectedIndex == 1 ? "SpecularAgbr20" :
                "SpecularAgbrNormalize20") :
                (comboBoxShaderTechnique.SelectedIndex == 0 ? "Specular" :
                comboBoxShaderTechnique.SelectedIndex == 1 ? "Specular20" :
                "SpecularNormalize20");

            //not required: device.Transform.World = worldMatrix;
            effect.SetValue("world", worldMatrix);
            effect.SetValue("worldViewProj",
                worldMatrix * viewMatrix * projMatrix);
            // Normal map is set in UpdateOutput, 
            // rest is set at Initialization.
            // Render the shader technique with the 
            // required number of passes.
            try
            {
                int passes = effect.Begin(0);
                for (int pass = 0; pass < passes; pass++)
                {
                    effect.BeginPass(pass);
                    sphere.DrawSubset(0);
                    effect.EndPass();
                } // for (pass)
            } // try
            finally
            {
                effect.End();
            } // finally
        } // if (rightMouseButtonPressed)
        else
        {
            // Code for no shader, just for testing
            device.Transform.World = worldMatrix;
            device.VertexFormat = CustomVertex.PositionNormalTextured.Format;
            sphere.DrawSubset(0);
        } // else
    } // try
    finally
    {
        // If BeginScene was called, EndScene must be called too!
        device.EndScene();
    } // finally

    // Finished
    device.Present();

所以,如果右键不被按下,它将使用选定的技术渲染带有着色器效果文件的球体。

着色器代码

ps2.0的代码并不复杂(至少如果您以前使用过着色器的话^^),所有着色器代码都可以在DirectionalNormalMapping.fx中找到(点光源也使用相同的东西,差别不大)。

// Pixel shader function
float4 PS_Specular20(VertexOutput_Specular20 In) : COLOR
{
    // Grab texture data
    float4 diffuseTexture = tex2D(diffuseTextureSampler, 
                                           In.diffTexCoord);
    float3 normalVector = (2.0 * tex2D(normalTextureSampler, 
                                     In.normTexCoord)) - 1.0;

    // Additionally normalize the vectors
    
    //not needed: normalize(In.lightVec);
    float3 lightVector = In.lightVec;  
    float3 viewVector = normalize(In.viewVec);

    // Compute the angle to the light
    float bump = saturate(dot(normalVector, lightVector));
    // Specular factor
    float3 reflect = 
        normalize(2 * bump * normalVector - lightVector);
    float spec = pow(saturate(dot(reflect, viewVector)), 
                                              shininess);

    float4 ambDiffColor = ambientColor + bump * diffuseColor;
    return diffuseTexture * ambDiffColor +
        bump * spec * specularColor * diffuseTexture.a;
} // PS_Specular20(.)

DXT5 r-a交换纹理模式也同样适用,只需更改一行:float3 normalVector = (2.0 * tex2D(normalTextureSampler, In.normTexCoord).agb) - 1.0;

好吧,这对于ps2.0来说效果很好,在ps1.1硬件上实现相同效果要难一些。首先:ps1.1中没有规范化函数,没有它,我们的viewVector会看起来很糟糕。因此,我们使用一个叫做NormalizeCubeMap.dds的小技巧(纹理必须未压缩),它包含了我们传递到viewVector的每个3D立方体贴图坐标的规范化xyz值。下一个问题是我们可用的指令数有限,只能使用8条,并且像pow这样的强大函数也不可能。使用.agb swizzle在ps1.1上会占用太多指令,而且由于所有这些问题,我们绝不可能不使用像素着色器汇编代码编写所有这些垃圾(这也不愉快,但比在高层着色器语言中玩弄8条指令更有趣)。

好的,让我们看一下着色器代码。首先,我们需要为该着色器设置所有采样器、常量和纹理坐标(就像我们在固定管道代码中会做的那样)。

    sampler[0] = (diffuseTextureSampler);
    sampler[1] = (normalTextureSampler);
    sampler[2] = (NormalizeCubeTextureSampler);
    PixelShaderConstant1[0] = ;
    PixelShaderConstant1[2] = <;diffuseColor>;
    PixelShaderConstant1[3] = <;specularColor>;
    PixelShader = asm
    {
        // Optimized for ps_1_1, uses all possible 8 instructions.
        ps_1_1
        // Helper to calculate fake specular power.
        def c1, 0, 0, 0, -0.25
        //def c2, 0, 0, 0, 4
        def c4, 1, 0, 0, 1
        // Sample diffuse and normal map
        tex t0
        tex t1
        // Normalize view vector (t2)
        tex t2
        // Light vector (t3)
        texcoord t3

嗯,已经有四个纹理指令来加载所有纹理坐标。常量c0保存环境光颜色,c1是一个稍后用于提高镜面功率的常量,c2是漫射颜色,c3是镜面颜色,c4保存另一个常量以帮助我们切换rgba到agbr。

t0保存漫射贴图,t1是法线贴图纹理坐标(与t0相同),t2是视向量,它将通过归一化立方体贴图进行规范化,t3保存光向量。最后,v0是从顶点着色器传递过来的一个小的辅助变量,返回光向量/3,以帮助我们计算float3 reflect = normalize(2 * bump * normalVector - lightVector);,这在ps1.1中显然是不可能的(顺便说一句:v1是空的,可以用于更高级的着色器,如次表面法线贴图或镜面贴图法线贴图,或者只是支持点光源)。

好的,回到像素着色器代码,首先我们需要使用lerp将DXT5压缩的rgba法线贴图值转换为agbr(将t1复制到r1并将alpha通道放入红色通道)。

        // v0 is lightVecDiv3!
        // Convert agb to xyz (costs 1 instuction)
        lrp r1.xyz, c4, t1.w, t1

顺便说一句:t1.r仍然保存着alpha通道,如果我们还需要的话。现在执行以下公式。

// Compute the angle to the light
bump[r0] = saturate(dot(normalVector[t1], lightVector[t3]));
// Specular factor
reflect[r1] = bump[r0] * normalVector[t1] - lightVectorDiv3[v0];
spec[r1] = saturate(dot(reflect[r1], viewVector[t2]));

使用以下ps1.1汇编。

        // Now work with r1 instead of t1
        dp3_sat r0.xyz, r1_bx2, t3_bx2
        mad r1.xyz, r1_bx2, r0, -v0_bx2
        dp3_sat r1, r1, t2_bx2

最后,使用以下公式使用alpha通道执行pow(spec)函数:spec = saturate(2*spec*spec-0.25);,同时在rgb通道中将环境光、漫射光和镜面光颜色与计算出的因子结合(疯狂地组合指令^^):return diffuseTexture * (ambientColor + bump[r0] * diffuseColor) + spec[r1.w] * specularColor;使用以下ASM代码。

        // Increase pow(spec) effect
        mul_x2_sat r1.w, r1.w, r1.w
        //we have to skip 1 mul because we lost 1 instruction because of agb
        //mul_x2_sat r1.w, r1.w, r1.w
        // r0 = r0 (bump) * diffuseColor + ambientColor
        mad r0.rgb, r0, c2, c0
        // Combine 2 instructions because we need 1 more to set alpha!
        // Sub -0.4 from fake pow(spec) to make it look more realistic
        +add_sat r1.w, r1.w, c1.w
        mul r0.rgb, t0, r0
        +mul_x2_sat r1.w, r1.w, r1.w
        mad r0.rgb, r1.w, c3, r0
        // Set alpha from texture to result color!
        // Can be combined too :)
        +mov r0.w, t0.w
    };

最后一条指令用于将漫射贴图中的任何alpha信息复制到最终颜色中,以支持alpha混合和alpha测试。ps1.1业务真是相当复杂,对吧?嗯,你只需要做一次,或者如果你在EA工作,你可能根本不必这样做(参见BattleField2,它甚至无法在PS1.3硬件上启动)。我认为支持旧硬件很好,而且当Doom3和HL2可以做到时,为什么其他人不能呢?^^

结论

希望这篇文章不会太长。请随时通过阅读和调试我的代码来阅读和了解更多关于法线贴图、fx着色器文件、Managed DirectX等内容,这里有太多的代码无法详细介绍。我希望我的小工具很有用,如果您有任何问题或改进意见,请随时在评论部分发表评论。

NormalMapCompressor可以自由地在您的项目中使用。如果您使用源代码,最好提及原始作者(嘿,就是我)。

历史

  • 2005-07-04(v1.0)- 初始版本。
  • 2005-07-05(v1.1)- 更新版本。

    新功能

    • 翻转x/y/z功能以导入任何其他保存的法线贴图格式。
    • Mipmaps终于可以工作了,图像数据大小现在将显示包含mipmap数据的尺寸。

    我还修复了以下bug:

    • 生成 mipmap不再依赖于AutoGenerateMipMap(它在保存时不起作用),而是使用TextureLoader.FilterTexture生成 mipmap。
    • 在主应用程序中直接保存dds已修复DXT5Switched,它保存了与DXT5相同的版本(如果您想要DXT5Switched输出,这是错误的),Batch Convert已经正确完成了。
    • 在初始化时发生任何问题的情况下,添加了一些额外的错误消息。
    • 修复了Auto-Update的bug,该bug总是更新所有图像而不是只更新更改的图像。自动更新现在工作正常,并且速度快了很多!
© . All rights reserved.