XNA 4 tutorial: Custom shader Effects

This tutorial is about custom shaders within the XNA 4 framework. A shader (in CAD tools often called material and in XNA called effect) is a programm executed on the graphics card to manipulate the visual presentation of an object or a whole scene. A shader can describe the surface of an object and how it interacts with light (e.g. matt, glossy, shiny, reflective, bumpiness, color). Shaders can also be used for motion (morphing, water, hair), for shadows or for post effects (motion blur, depth of field). This tutorial is about integrating shaders into a XNA 4 game engine not about writing shaders in HLSL code. Please have a look at the following web resources to get familiar with shaders in general and how to write them:
[singlepic id=2135 w=200 float=right]

Writing custom Effect classes

XNA 4 already provides five default shaders called Effects: BasicEffect, EnvironmentMapEffect, DualTextureEffect, AlphaTestEffect and SkinnedEffect. These Effect classes can be customized in some degree by their properties. Amazingly these few Effects last out writing quite good looking games! On the other side there are still gaps like drawing water, halo lights, post effects and more. This article shows three solutions, how to extend a game engine with custom Effect classes: the fast, the hack and the clean solution.

The fast solution

Load a custom shader (.fx) file as Effect class, set its shader parameters, done.

   var effect = Content.Load("effects/Custom"); // load shader.fx file as C# Effect class
   // Initialize needed effect parameters by their index names
   effect.Parameters["matWorldViewProj"].SetValue(worldMatrix * viewMatrix * projMatrix);
   effect.Parameters["matInverseWorld"].SetValue(Matrix.Inverse(worldMatrix));
   effect.Parameters["vLightDirection"].SetValue(lightDirection);
   effect.Parameters["ColorMap"].SetValue(colorMap);

This is the fast solution. The drawback is setting up the shader parameters correctly: each shader parameter has to be initialized by using its index name, which requires some knowledge about the shader interna. This might also be error-prone, since the needed index names have to be looked up in the shader file. Moreover there is no strict common interface for handling more than one Effect. Each Effect has to be set up manually one by one. XNA comes with a cleaner solution by providing special Effect classes for each shader.

The hack solution

In order to integrate a custom Effect class seamless into the XNA framework, it is a good idea to derive it from the XNA Effect class, so each Effect can be exchanged anytime, anywhere, custom-made or XNA-made. Unfortunately deriving from the XNA Effect class requires writing an own content pipeline. But there is a hack to avoid it:

   var tempEffect = Content.Load("effects/Custom"); // Load shader file as Effect
   customEffect = new CustomEffect(tempEffect); // copy loaded Effect into custom Effect class
   tempEffect = null; // free for garbage collection
   customEffect.WorldViewProj = worldMatrix * viewMatrix * projMatrix;
   customEffect.InverseWorld = Matrix.Inverse(worldMatrix);
   customEffect.LightDirection = lightDirection;
   customEffect.ColorMap = colorMap;

The „hack solution“ is cleaner and more consistent than the previous „fast solution“. No more error-prone string indices to mess around! Everything is strictly typed by the C# programming language and the IDE helps proposing the correct shader parameter names. Interfaces can be used to handle more than one Effect with same shader parameters. The hack works fine and it appears to have no disadvantage – except it still feels like a hack…

The clean solution

This article describes how to load custom Effect classes via the content pipeline. Whoa, the content pipeline? At first glance this seems like a lot of complex work, especially compared to the previous „hack solution“. But it takes less than 5 minutes to add the existing content pipeline to your own Visual Studio solution. The only constant work is setting up the Effect processor for each shader (.fx) file to the new DerivedEffect processor in the Visual Studio IDE.

   var customEffect = content.LoadDerivedEffect("effects/Custom");
   customEffect.WorldViewProj = worldMatrix * viewMatrix * projMatrix;
   customEffect.InverseWorld = Matrix.Inverse(worldMatrix);
   customEffect.LightDirection = lightDirection;
   customEffect.ColorMap = colorMap;

Custom Effect class generator

Everything seems fine now. But all this clean happiness comes at a price: implementing every custom Effect class is an annoying lot of work. So I wrote a EffectGenerator generating custom Effect classes from a given shader file. It might not cover all cases, but it still saves you from writing each parameter property by hand. Here is an example of a generated LambertEffect class:

class LambertEffect : BaseEffect
{
        public enum EffectTechniques
        {
                Main
        }

        #region Properties

        public Vector3 Lamp0Position { get; set; }
        public Color Lamp0 { get; set; }
        public Color AmbientLight { get; set; }
        public Texture2D DiffuseTexture { get; set; }

        public EffectTechniques Technique { get; set; }

        #endregion

        public LambertEffect(GraphicsDevice graphicsDevice, byte[] effectCode) : base(graphicsDevice, effectCode)
        {
                Name = "Lambert";
                Lamp0Position = new Vector3(-0.5f, 2f, 1.25f);
                Lamp0 = new Color(1f, 1f, 1f);
                AmbientLight = new Color(0.07f, 0.07f, 0.07f);
        }

        protected override void OnApply()
        {
                Parameters["WorldITXf"].SetValue(Matrix.Transpose(Matrix.Invert(World)));
                Parameters["WvpXf"].SetValue(World * View * Projection);
                Parameters["WorldXf"].SetValue(World);
                Parameters["ViewIXf"].SetValue(Matrix.Invert(View));
                Parameters["Lamp0Pos"].SetValue(Lamp0Position);
                Parameters["Lamp0Color"].SetValue(Lamp0.ToVector3());
                Parameters["AmbiColor"].SetValue(AmbientLight.ToVector3());
                Parameters["ColorTexture"].SetValue(DiffuseTexture);

                switch (Technique)
                {
                        case EffectTechniques.Main:
                                CurrentTechnique = Techniques["Main"];
                                break;
                        default:
                                CurrentTechnique = Techniques["Main"];
                                break;
                }
        }
}

Using custom Effects within the simple game engine

The following diagramm shows how an Effect is integrated into the simple game engine.
[singlepic id=2129 float=none]
Please note that the Entity class is now derived from the XNA DrawableComponent class. Thus each Entity may now be responsible for loading its own content, there are two ways of creating a new Entitiy:

protected override void Initialize() // within main Game class
{
    // Initialize camera
    var camera = new QuaternionCamera(MathHelper.ToRadians(45.0f), GraphicsDevice.Viewport.AspectRatio, 1.0f, 1000.0f);
    camera.Translate(Vector3.Backward, 10);

    // Entity 1: initialized by injection
    BoxEntityComponent = new ModelEntityComponent(this, camera);
    BoxEntityComponent.Translate(Vector3.Right, 6.0f);

    // Entity 2: initialized by subclassing
    TentacleEntityComponent = new TentacleEntityComponent(this, camera);

    // Initialize input components
    var cameraInputComponent = new CameraInputComponent(this, camera);
    var tentacleEntityInputComponent = new TentacleEntityInputComponent(this, TentacleEntityComponent);

    // Initialize game components
    Components.Add(cameraInputComponent);
    Components.Add(tentacleEntityInputComponent);
    Components.Add(TentacleEntityComponent);
    Components.Add(BoxEntityComponent);

    base.Initialize();
}

Initialize Entity by injection

Within the main Game class make a new instance of the generic ModelEntityComponent, then load and inject an Effect and a Model into it.

protected override void LoadContent() // within main Game class
{
    BoxEntityComponent.Model = Content.Load("models/ChamferBox");
    var boxEntityEffect = Content.LoadDerivedEffect();
    boxEntityEffect.DiffuseTexture = Content.Load("textures/pak/metal/metal009_diffuse");
    boxEntityEffect.NormalMapTexture = Content.Load("textures/pak/metal/metal009_normal");
    boxEntityEffect.Environment = Content.Load("textures/nvidia/DefaultEnvironmentMap");
    BoxEntityComponent.Effect = boxEntityEffect;

    base.LoadContent();
}

Initialize Entity by subclassing

Instead of instantiating a ModelEntityComponent and injecting the required content, it is also possible to derive a new Entity class from ModelEntityComponent. Each derived class is then able to load its own content including textures, shaders and model mesh.

class TentacleEntityComponent : ModelEntityComponent
{
    private List effects = new List();
    private int effectsIndex = 0;

    private SpriteBatch spriteBatch;
    private SpriteFont font;

    public TentacleEntityComponent(Game game, ICamera camera)
        : base(game, camera)
    {
    }

    protected override void LoadContent()
    {
        var content = Game.Content;

        // Create a new SpriteBatch, which can be used to draw font
        spriteBatch = new SpriteBatch(GraphicsDevice);
        font = content.Load("fonts/Default");

        // Load and set model
        Model = content.Load("models/TentacleThing");

        // Load effects
        var defaultDiffuseMap = Game.Content.Load("textures/pak/metal/metal009_diffuse");

        var lambertEffect = content.LoadDerivedEffect();
        lambertEffect.DiffuseTexture = defaultDiffuseMap;
        effects.Add(lambertEffect);

        var basicEffect = new BasicEffect(Game.GraphicsDevice);
        basicEffect.Name = "BasicEffect";
        basicEffect.EnableDefaultLighting();
        basicEffect.PreferPerPixelLighting = true;
        basicEffect.Texture = defaultDiffuseMap;
        basicEffect.TextureEnabled = true;
        basicEffect.SpecularPower = 30f;
        effects.Add(basicEffect);

        // ... more effects ...

        // Set start effect
        SwitchNextEffect();

        base.LoadContent();
    }

    public void SwitchNextEffect()
    {
        Effect = effects[effectsIndex];
        effectsIndex++;
        if (effectsIndex >= effects.Count)
        {
            effectsIndex = 0;
        }
    }

    public override void Draw(GameTime gameTime)
    {
        // The SpriteBatch added below to draw the debug text is changing some
        // needed render states, so they are reset here.
        Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;
        Game.GraphicsDevice.BlendState = BlendState.Opaque;

        // 1. Draw model mesh
        base.Draw(gameTime); 

        // 2. Draw debug text (displayed always on top of the model mesh)
        spriteBatch.Begin();
        var text = Effect.Name;
        var fontOrigin = font.MeasureString(text) / 2;
        spriteBatch.DrawString(font, text, fontOrigin + new Vector2(5f, 0),
             Color.LimeGreen, 0, fontOrigin, 1.0f, SpriteEffects.None, 0.5f);
        spriteBatch.End();
    }
}

Please see the full source code below for further details. The next tutorial will be about cubemaps and environmap effects.

Missing tangents and binormals?

Please note that most shader effects need precalculated tangent and binormal data in order to calculate the lighting of a 3D model. Unfortunately some 3D models come without these data. Under the headline „Generating Additional Data“ is a Tangent Model Processor, which generates tangent and binormal data. Make sure, the Content Pipeline project has a reference to the „Tangent Model Processor“ project, rebuild the Content Pipeline project and then open the file properties of the 3D model and set the „Model Processor“ to „ModelProcessorWithTangents“.

Resources


NOTE: You may have noticed, that the lighting direction is sometimes different on switching to the next shader effect? This is due to different lighting calculation in each shader file. I collected those files from several sources on the internet and I guess, they may presume different coordinate systems: left-handed VS right-handed.

Download solution files
NOTE: If you get strange „missing file errors“ on compiling the zipped source, then build every project on its own once. After this is done, everything should work as usual again. I don’t know, why Visual Studio doesn’t use the build-order correctly.


Beitrag veröffentlicht

in

von