1
\$\begingroup\$

I am working on a small game engine and having some troubles when figuring out how to properly map more complex textures to their meshes. I am loading models using the .OBJ format and as far as I can tell, my loader is properly loading all the data into a Vertex list, as the model properly renders, and diffuse shading is applied properly with the normals from the file.

However, when I bind the following texture to its mesh, I get following results. enter image description here

enter image description here

To bind this texture, I simply did this code:

int width, height;
unsigned char * data = SOIL_load_image("Medieval_House_Diff.png", &width, &height, 0, 0);
GLuint textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D);

Here is my code for reading the OBJ file

while ((line = reader.getLine()) != 0)
    {
        if (line[0] == 'f' && line[1] == ' ')
        {
            bool quad = false;
            objFP f1 = { 0 };
            objFP f2 = { 0 };
            objFP f3 = { 0 };
            objFP f4 = { 0 };
            strtok_s(line, " ", &context);
            token = strtok_s(0, " ", &context);
            if (sscanf_s(token, "%u/%u/%u", &f1.vPos, &f1.tPos, &f1.nPos) == 3)
            {
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u/%u/%u", &f2.vPos, &f2.tPos, &f2.nPos);
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u/%u/%u", &f3.vPos, &f3.tPos, &f3.nPos);
                token = strtok_s(0, " ", &context);
                if (token)
                {
                    quad = true;
                    sscanf_s(token, "%u/%u/%u", &f4.vPos, &f4.tPos, &f4.nPos);
                }
            }
            else if (sscanf_s(token, "%u//%u", &f1.vPos, &f1.nPos) == 2)
            {
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u//%u", &f2.vPos, &f2.nPos);
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u//%u", &f3.vPos, &f3.nPos);
                token = strtok_s(0, " ", &context);
                if (token)
                {
                    quad = true;
                    sscanf_s(token, "%u//%u", &f4.vPos, &f4.nPos);
                }
            }
            else if (sscanf_s(token, "%u/%u", &f1.vPos, &f1.tPos) == 2)
            {
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u/%u", &f2.vPos, &f2.tPos);
                token = strtok_s(0, " ", &context);
                sscanf_s(token, "%u/%u", &f3.vPos, &f3.tPos);
                token = strtok_s(0, " ", &context);
                if (token)
                {
                    quad = true;
                    sscanf_s(token, "%u/%u", &f4.vPos, &f4.tPos);
                }
            }
            else
            {
                f1.vPos = (GLuint)atoi(token);
                token = strtok_s(0, " ", &context);
                f2.vPos = (GLuint)atoi(token);
                token = strtok_s(0, " ", &context);
                f3.vPos = (GLuint)atoi(token);
                token = strtok_s(0, " ", &context);
                if (token)
                {
                    quad = true;
                    f4.vPos = (GLuint)atoi(token);
                }
            }

            faces[fCount++] = f1;
            faces[fCount++] = f2;
            faces[fCount++] = f3;
            //inds.push_back(f1.vPos - 1);
            //inds.push_back(f2.vPos - 1);
            //inds.push_back(f3.vPos - 1);
            if (quad)
            {
                faces[fCount++] = f1;
                faces[fCount++] = f3;
                faces[fCount++] = f4;
                //inds.push_back(f1.vPos - 1);
                //inds.push_back(f3.vPos - 1);
                //inds.push_back(f4.vPos - 1);
            }
            continue;
        }
        else if (line[0] == 'v' && line[1] == ' ')
        {
            strtok_s(line, " ", &context);
            vertices[pCount].position.x = strtof(strtok_s(0, " ", &context), 0) * modelScale;
            vertices[pCount].position.y = strtof(strtok_s(0, " ", &context), 0) * modelScale;
            vertices[pCount++].position.z = strtof(strtok_s(0, " ", &context), 0) * modelScale;
        }
        else if (line[0] == 'v' && line[1] == 't')
        {
            strtok_s(line, " ", &context);
            tempTextures[tCount].x = strtof(strtok_s(0, " ", &context), 0);
            tempTextures[tCount++].y = 1  - strtof(strtok_s(0, " ", &context), 0);
        }
        else if (line[0] == 'v' && line[1] == 'n')
        {
            strtok_s(line, " ", &context);
            tempNormals[nCount].x = strtof(strtok_s(0, " ", &context), 0);
            tempNormals[nCount].y = strtof(strtok_s(0, " ", &context), 0);
            tempNormals[nCount++].z = strtof(strtok_s(0, " ", &context), 0);
        }
    }
    reader.release();

    bool hasTextures = tCount > 0;
    bool hasNormals = nCount > 0;


    GLuint * indices = new GLuint[fCount];
    for (GLuint i = 0; i < fCount; i++)
    {

        GLuint index = faces[i].vPos - 1;
        indices[i] = index;
            if (hasTextures)
                vertices[faces[i].vPos - 1].texture = tempTextures[faces[i].tPos - 1];
            if (hasNormals)
                vertices[index].normal = tempNormals[faces[i].nPos - 1];
    }

I have not been able to find a resource online that deals with properly loading a texture such as this, so it would be appreciated if someone with knowledge on such topics could tell me what steps I might be missing to properly map this texture to its mesh.

\$\endgroup\$
  • \$\begingroup\$ It looks something is not right with your UV coords. Can you maybe show the part where you extract them? \$\endgroup\$ – Bálint Nov 6 '16 at 23:07
0
\$\begingroup\$

My first guess is that you have incorrect UV coordinates, or you're not properly loading them. Here is my WavefrontModel class from my game engine, maybe you can compare your code to mine(which works).

using System;
using System.IO;
using GrimoireEngine.Framework.Maths;
using GrimoireEngine.Framework.OpenGL.Texturing;
using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace GrimoireEngine.Framework.OpenGL.Modeling
{
    public class WavefrontModel
    {
        /// <summary>
        /// The Vertices of this Model
        /// </summary>
        public Vector3[] Vertices;
        /// <summary>
        /// The TexCoords of this Model
        /// </summary>
        public Vector2[] TexCoords;
        /// <summary>
        /// The Normals of this Model
        /// </summary>
        public Vector3[] Normals;
        /// <summary>
        /// The Faces of this Model
        /// </summary>
        public Face[] Faces;
        /// <summary>
        /// The Original File Source
        /// </summary>
        public string ModelSource { get; set; }
        /// <summary>
        /// The Name of this Model
        /// </summary>
        public string Name { get; set; }
        /// <summary>
        /// The Material of this Model
        /// </summary>
        public Material Material { get; set; }

        /// <summary>
        /// The Triangle Count of this model.
        /// </summary>
        public int TriCount
        {
            get
            {
                return Faces.Length;
            }
        }

        public WavefrontModel()
        {

        }

        public WavefrontModel(string modelPath, Material material)
        {
            this.ModelSource = modelPath;
            this.Material = material;
        }

        public WavefrontModel(string modelPath)
        {
            this.ModelSource = modelPath;
            this.Material = null;
        }

        public WavefrontModel(string[] data)
        {
            this.ModelSource = String.Empty;
            this.Material = null;
            Load(data);
        }

        public WavefrontModel(string[] data, Material material)
        {
            this.ModelSource = String.Empty;
            this.Material = material;
            Load(data);
        }

        /// <summary>
        /// Loads a model from the desired Wavefront.obj source given
        /// at constructor initialization.
        /// </summary>
        public void Load()
        {
            Load(this.ModelSource);
        }

        /// <summary>
        /// Loads a model from a Wavefront.obj located on disk.
        /// </summary>
        /// <param name="file"></param>
        public void Load(string file)
        {
            Parse(File.ReadAllLines(file));
        }

        /// <summary>
        /// Loads a model from a Wavefront.obj located on disk.
        /// </summary>
        /// <param name="data"></param>
        public void Load(string[] data)
        {
            Parse(data);
        }

        /// <summary>
        /// Clears all Model-Buffer Data
        /// </summary>
        public void Clear()
        {
            Array.Clear(Vertices, 0, Vertices.Length);
            Array.Clear(TexCoords, 0, TexCoords.Length);
            Array.Clear(Normals, 0, Normals.Length);
            Array.Clear(Faces, 0, Faces.Length);
        }

        /// <summary>
        /// Parses Wavefront data from desired array.
        /// </summary>
        /// <param name="data"></param>
        public void Parse(string[] data)
        {
            // Create File Manifest
            int totalVertices = 0;
            int totalNormals = 0;
            int totalTextureCoordinates = 0;
            int totalFaces = 0;
            for (int i = 0; i < data.Length; i++)
            {
                char firstChar = data[i][0];
                switch (firstChar)
                {
                    case 'v':
                        char secondChar = data[i][1];
                        switch (secondChar)
                        {
                            case ' ':
                                totalVertices++;
                                break;
                            case 't':
                                totalTextureCoordinates++;
                                break;
                            case 'n':
                                totalNormals++;
                                break;
                        }
                        break;
                    case 'f':
                        totalFaces++;
                        break;
                }
            }
            // Create the Buffers
            Vertices = new Vector3[totalVertices];
            Normals = new Vector3[totalNormals];
            TexCoords = new Vector2[totalTextureCoordinates];
            Faces = new Face[totalFaces];
            // Create File Reading Buffers
            string[] fileBuffer = new string[20];
            string[] indiceBuffer = new string[3];
            Vertex[] vertexBuffer = new Vertex[3];
            // Iterators
            int verticesIterator = 0;
            int normalsIterator = 0;
            int textureCoordinatesIterator = 0;
            int facesIterator = 0;
            for (int line = 0; line < data.Length; line++)
            {
                SplitStringFast(data[line], ' ', fileBuffer);
                char firstChar = data[line][0];
                switch (firstChar)
                {
                    case 'v':
                        char secondChar = data[line][1];
                        switch (secondChar)
                        {
                            case ' ':
                                Vector3 vertex = new Vector3
                                {
                                    X = GrimoireMath.ParseFloatFast(fileBuffer[1]),
                                    Y = GrimoireMath.ParseFloatFast(fileBuffer[2]),
                                    Z = GrimoireMath.ParseFloatFast(fileBuffer[3])
                                };
                                Vertices[verticesIterator] = vertex;
                                verticesIterator++;
                                break;
                            case 't':
                                Vector2 textureCoordinate = new Vector2
                                {
                                    X = GrimoireMath.ParseFloatFast(fileBuffer[1]), // U
                                    Y = -GrimoireMath.ParseFloatFast(fileBuffer[2]) // V (Inverted)
                                };
                                TexCoords[textureCoordinatesIterator] = textureCoordinate;
                                textureCoordinatesIterator++;
                                break;
                            case 'n':
                                Vector3 normal = new Vector3
                                {
                                    X = GrimoireMath.ParseFloatFast(fileBuffer[1]),
                                    Y = GrimoireMath.ParseFloatFast(fileBuffer[2]),
                                    Z = GrimoireMath.ParseFloatFast(fileBuffer[3])
                                };
                                Normals[normalsIterator] = normal;
                                normalsIterator++;
                                break;
                        }
                        break;
                    case 'f':
                        for (int i = 0; i < 3; i++)
                        {
                            SplitStringFast(fileBuffer[i + 1], '/', indiceBuffer);
                            Vertex indices = new Vertex
                            {
                                VertexIndice = GrimoireMath.ParsePositiveInt32Fast(indiceBuffer[0]) - 1,
                                TextureIndice = GrimoireMath.ParsePositiveInt32Fast(indiceBuffer[1]) - 1,
                                NormalIndice = GrimoireMath.ParsePositiveInt32Fast(indiceBuffer[2]) - 1
                            };
                            vertexBuffer[i] = indices;
                        }
                        Faces[facesIterator] = new Face(vertexBuffer);
                        facesIterator++;
                        break;
                }
            }
        }

        /// <summary>
        /// A custom implementation of String.Split(). Realistically, this 
        /// function is not much faster than what .NET offers; it gains speed
        /// more from a preset buffer mechanism. There is no bounds checking done
        /// for the buffer, which is done by design to assure optimal performance.
        /// </summary>
        /// <param name="value"></param>
        /// <param name="delimiter"></param>
        /// <param name="buffer"></param>
        /// <returns></returns>
        private static void SplitStringFast(string value, char delimiter, string[] buffer)
        {
            int resultIndex = 0;
            int startIndex = 0;
            for (int i = 0; i < value.Length; i++)
            {
                if (value[i] == delimiter)
                {
                    buffer[resultIndex] = value.Substring(startIndex, i - startIndex);
                    resultIndex++;
                    startIndex = i + 1;
                }
            }
            buffer[resultIndex] = value.Substring(startIndex, value.Length - startIndex);
        }

        /// <summary>
        /// Renders the Model using deprecated immediate mode. This
        /// function exists only for testing purposes.
        /// </summary>
        public void Render()
        {
            GL.Enable(EnableCap.Texture2D);
            GL.Color3(Material.AmbientColor);
            GL.BindTexture(TextureTarget.Texture2D, Material.Diffuse);
            GL.Begin(PrimitiveType.Triangles);
            for (int i = 0; i < Faces.Length; i++)
            {
                /**
                 * One
                 */
                GL.Normal3(Normals[Faces[i].One.NormalIndice]);
                GL.TexCoord2(TexCoords[Faces[i].One.TextureIndice]);
                GL.Vertex3(Vertices[Faces[i].One.VertexIndice]);
                /**
                 * Two
                 */
                GL.Normal3(Normals[Faces[i].Two.NormalIndice]);
                GL.TexCoord2(TexCoords[Faces[i].Two.TextureIndice]);
                GL.Vertex3(Vertices[Faces[i].Two.VertexIndice]);
                /**
                 * Three
                 */
                GL.Normal3(Normals[Faces[i].Three.NormalIndice]);
                GL.TexCoord2(TexCoords[Faces[i].Three.TextureIndice]);
                GL.Vertex3(Vertices[Faces[i].Three.VertexIndice]);
            }
            GL.End();
        }
    }
}

You will also need:

using System;

namespace GrimoireEngine.Framework.OpenGL.Modeling
{
    public struct Vertex : IEquatable<Vertex>
    {

        public int VertexIndice;
        public int TextureIndice;
        public int NormalIndice;

        public static Vertex Empty
        {
            get
            {
                return new Vertex();
            }
        }

        public Vertex(int[] indices)
        {
            this.VertexIndice = indices[0];
            this.NormalIndice = indices[1];
            this.TextureIndice = indices[2];
        }

        public Vertex(int vertexIndice, int normalIndice, int textureIndice)
        {
            this.VertexIndice = vertexIndice;
            this.TextureIndice = textureIndice;
            this.NormalIndice = normalIndice;
        }

        public bool Equals(Vertex other)
        {
            return VertexIndice == other.VertexIndice &&
                   TextureIndice == other.TextureIndice &&
                   NormalIndice == other.NormalIndice;
        }

        public override bool Equals(object obj)
        {
            return obj is Vertex && Equals((Vertex)obj);
        }

        public static bool operator ==(Vertex v1, Vertex v2)
        {
            return v1.Equals(v2);
        }

        public static bool operator !=(Vertex v1, Vertex v2)
        {
            return !(v1 == v2);
        }

        /// <summary>
        /// Order = VertexIndice, TextureIndice, NormalIndice
        /// </summary>
        /// <returns></returns>
        public int[] ToArray()
        {
            return new[] { VertexIndice, TextureIndice, NormalIndice };
        }

        public override int GetHashCode()
        {
            unchecked
            {
                int hashCode = VertexIndice;
                hashCode = (hashCode * 31) ^ TextureIndice;
                hashCode = (hashCode * 31) ^ NormalIndice;
                return hashCode;
            }
        }
    }
}

And

using System;

namespace GrimoireEngine.Framework.OpenGL.Modeling
{
    public struct Face : IEquatable<Face>
    {
        public Vertex One;
        public Vertex Two;
        public Vertex Three;

        public static Face Empty
        {
            get
            {
                return new Face();
            }
        }

        public Face(Vertex[] vertexIndices)
        {
            this.One = vertexIndices[0];
            this.Two = vertexIndices[1];
            this.Three = vertexIndices[2];
        }

        public Face(Vertex one, Vertex two, Vertex three)
        {
            this.One = one;
            this.Two = two;
            this.Three = three;
        }

        public bool Equals(Face other)
        {
            return One == other.One && Two == other.Two && Three == other.Three;
        }

        public override bool Equals(object obj)
        {
            return obj is Face && Equals((Face)obj);
        }

        public static bool operator ==(Face f1, Face f2)
        {
            return f1.Equals(f2);
        }

        public static bool operator !=(Face f1, Face f2)
        {
            return !(f1.Equals(f2));
        }

        public Vertex[] ToArray()
        {
            return new[] { One, Two, Three };
        }

        public override int GetHashCode()
        {
            unchecked
            {
                int hashCode = One.GetHashCode();
                hashCode = (hashCode * 31) ^ Two.GetHashCode();
                hashCode = (hashCode * 31) ^ Three.GetHashCode();
                return hashCode;
            }
        }
    }
}

And my Material class (Current work in progress because I just created my own accelerated Bitmap class and it is not using it yet):

using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using OpenTK.Graphics.OpenGL;

namespace GrimoireEngine.Framework.OpenGL.Texturing
{
    public enum MaterialComponent
    {
        /// <summary>
        /// Represents a Diffuse Texture Component
        /// </summary>
        Diffuse
    }

    public class Material
    {
        /// <summary>
        /// The name of the Material
        /// </summary>
        public string Name;
        /// <summary>
        /// The Diffuse Material Component
        /// </summary>
        public int Diffuse;
        /// <summary>
        /// The Ambient Color for the Material
        /// </summary>
        public Color AmbientColor;

        public bool HasDiffuseComponent
        {
            get
            {
                return Diffuse != 0;
            }
        }

        public Material()
        {
            this.AmbientColor = Color.White;
            this.Diffuse = 0;
        }

        public Material(string diffuse, TextureMinFilter minFilter = TextureMinFilter.Nearest, TextureMagFilter magFilter = TextureMagFilter.Nearest)
        {
            this.AmbientColor = Color.White;
            if (diffuse != null)
            {
                if (File.Exists(diffuse))
                {
                    Bitmap diffuseImage = new Bitmap(diffuse);
                    LoadDiffuseComponent(diffuseImage, minFilter, magFilter);
                }
                else
                {
                    throw new FileNotFoundException("Could not locate: " + diffuse);
                }
            }
        }

        /// <summary>
        /// Loads a Bitmap as the Diffuse Component for this Material.
        /// </summary>
        /// <param name="diffuseImage"></param>
        public void LoadDiffuseComponent(Bitmap diffuseImage)
        {
            this.Diffuse = UploadBitmap(diffuseImage, TextureMinFilter.Nearest, TextureMagFilter.Nearest);
        }

        /// <summary>
        /// Loads a Bitmap as the Diffuse Component for this Material.
        /// </summary>
        /// <param name="diffuseImage"></param>
        /// <param name="minFilter"></param>
        /// <param name="magFilter"></param>
        public void LoadDiffuseComponent(Bitmap diffuseImage, TextureMinFilter minFilter, TextureMagFilter magFilter)
        {
            this.Diffuse = UploadBitmap(diffuseImage, minFilter, magFilter);
        }

        /// <summary>
        /// Loads a Bitmap as a Diffuse texture with full control.
        /// </summary>
        /// <param name="bitmap">The bitmap.</param>
        /// <param name="minFilter"></param>
        /// <param name="magFilter"></param>
        public static int UploadBitmap(Bitmap bitmap, TextureMinFilter minFilter, TextureMagFilter magFilter)
        {
            int bind;
            GL.Enable(EnableCap.Texture2D);
            GL.GenTextures(1, out bind);
            GL.BindTexture(TextureTarget.Texture2D, bind);
            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)minFilter);
            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)magFilter);
            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
            BitmapData data = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadOnly,
                System.Drawing.Imaging.PixelFormat.Format32bppArgb);
            GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, data.Width, data.Height, 0,
                OpenTK.Graphics.OpenGL.PixelFormat.Bgra, PixelType.UnsignedByte, data.Scan0);
            bitmap.UnlockBits(data);
            GL.BindTexture(TextureTarget.Texture2D, 0);
            GL.Disable(EnableCap.Texture2D);
            return bind;
        }

        /// <summary>
        /// Binds this texture instance to OpenGL.
        /// </summary>
        public void Bind()
        {
            GL.BindTexture(TextureTarget.Texture2D, this.Diffuse);
        }

        /// <summary>
        /// Binds a texture to the OpenGL instance.
        /// </summary>
        /// <param name="textureBind">The texture bind.</param>
        public static void Bind(int textureBind)
        {
            GL.BindTexture(TextureTarget.Texture2D, 0);
        }

        /// <summary>
        /// Unbinds the texture for this material instance.
        /// </summary>
        public void Unbind()
        {
            GL.BindTexture(TextureTarget.Texture2D, 0);
        }
    }
}

I also invert my Texture coordinates, because OpenGL is defaulted to bottom left-hand origin. DirectX is upper left as origin. This may or may not be an issue for you, but it's useful information nonetheless.

And general Texture Coordinate information:

https://en.wikipedia.org/wiki/Texture_mapping

\$\endgroup\$
  • \$\begingroup\$ Can't say I see the difference looking at your code and what I posted, other than mine handling quads and ultimately setting up the vertex and index buffers differently. I am mainly skeptical that my loader is wrong because of this other mesh I rendered with its texture: [link]imgur.com/pbU3rNE .And here is the same mesh rendered in the engine Unity. [link]imgur.com/pHAfUtW . Obviously the seams are messed up in mine but to me it certainly looks like the texture coordinates were loaded properly for that mesh. \$\endgroup\$ – Rilgon Nov 7 '16 at 1:37
  • \$\begingroup\$ @Rilgon I honestly don't know what to tell you. It's certainly a uv mapping bug. \$\endgroup\$ – Krythic Nov 7 '16 at 1:51
  • \$\begingroup\$ @Rilgon I see you're using texture repeat, though. You shouldn't need to do that. Look at my Material class again. Your min/mag filters could be Linear/Nearest. \$\endgroup\$ – Krythic Nov 7 '16 at 1:56
  • 1
    \$\begingroup\$ I ported your code into an OpenTK renderer I had(if you don't mind haha), and that model and texture are working well: [Link]imgur.com/5VwKaGl .I will look more in depth and hopefully find what is the difference between this and my c++ code. Thank you for the help. \$\endgroup\$ – Rilgon Nov 7 '16 at 2:45
  • \$\begingroup\$ In case you end up being curious as to what was my issue, it was that I did not have a proper understanding of the OBJ multi indexing. For some reason I thought that if you keep overriding the texture and normal index for the same vertex index from the OBJ, it would still be fine. But you actually have to repeat certain vertices that have different texture coordinates/normals, which is why my textures were wrong for models with more complex textures. For now I going to 'bake' the indexing into the vertex data from OBJ, and draw them using glDrawArrays, until I better understand. \$\endgroup\$ – Rilgon Nov 7 '16 at 15:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.