Search Unity

Showcase Dice with randomized icons (shader/script)

Discussion in 'Scripting' started by jameslroll, Jul 24, 2022.

  1. jameslroll

    jameslroll

    Joined:
    Aug 26, 2014
    Posts:
    3
    Recently, I participated in the GMTK Gam Jam with the team: Too Many Cooks. The theme was "roll the dice" and it lasted 2 days. Our submission, took it it maybe a little too literally, and made dice a core mechanic. However, instead of rolling numbers, we made it so the player can roll an ability or disability, represented as an icon. I was able to come up with a pretty interesting method for generating the die faces and wanted to share it.



    Abstract
    The dice material system allows for each individual face to be given a sprite texture that is drawn directly on the material using a custom shader. The process includes: preparing the mesh and sprites, and writing the shader and script to control the faces. An alternative method for displaying the faces could be to create a world space canvas and use images to display the sprites. This method reduces draw calls and other overhead of using UI while allowing the faces to be lit by the scene.

    The Mesh
    A basic cube with beveled edges provided a base for the die. This method also works for non-six sided dice but the sprites would have to be designed to fit a trilateral surface. In our case, they were more suited on a quadrilateral surface. There consists of two UV channels: one for the surface texture, and another to represent the face in sprite space. For the secondary UV, each face was fit into a 4x4 grid space. Although a 3x3 grid space would suffice, a 4x4 space allows for up to 16 unique sides. The specific order is irrelevant, so long as it moves from left-to-right, top-to-bottom for later calculations.



    The Sprites
    To make this method lightweight, the material receives a single texture that represents the displayed faces. We use a single sprite atlas with multiple sprites defined in the texture importer. Thus the one draw-back of this method is that all faces of the die must be present on a single texture (the atlas); of course, it's also more efficient to stream a single texture rather than multiple.

    The Shader
    There are 3 essential properties for this shader: the grid size (4), the atlas texture, and the remap texture. Additional properties can be used to represent the base surface. The remap texture, which is generated by the script as a 4-channel 4x4 texture, encodes the position (xy) and size (zw) for each face's grid into the pixel. The current grid is calculated from the secondary UVs and the remap is sampled using that grid. The sprite atlas can then be sampled by converting the normalized face coordinates into the sprite's space using the remapped sample. It sounds more complicated than it is, the code is only a few lines:

    Code (ShaderLab):
    1. const float2 primary_uv = IN.uv_MainTex;
    2. const float2 secondary_uv = IN.uv2_Atlas;
    3. const fixed factor = 1.0 / _GridSize;
    4. const float2 grid = floor(secondary_uv * _GridSize) / _GridSize;
    5. const float2 face_uv = (secondary_uv - grid) / factor;
    6.  
    7. half4 remap = tex2D (_Remap, float2(grid.x, 1.0 - grid.y - factor));
    8.  
    9. fixed4 main_col = tex2D (_MainTex, primary_uv) * _Color;
    10. fixed4 face_col = tex2D (_Atlas, remap.xy + remap.zw * face_uv);
    11. fixed4 final_col = lerp(main_col, face_col.rgb, face_col.a);
    The Script
    Finally, we can write a script to connect everything together. The material for the die is supplied with the grid size, atlas, and remap textures. The atlas is assigned by the user, while the remap is generated to match the grid size.

    Code (CSharp):
    1. Texture2D remap = new(gridSize, gridSize, TextureFormat.RGBAHalf, false)
    2. {
    3.    filterMode = FilterMode.Point,
    4.    wrapMode = TextureWrapMode.Clamp,
    5. };
    6.  
    7. material.SetInt("_GridSize", gridSize);
    8. material.SetTexture("_Atlas", atlas);
    9. material.SetTexture("_Remap", remap);
    Then, each face needs to be mapped in the remap texture as a grid space. The sprite provides us with its rectangle on the atlas, this value is normalized and encoded into the pixel's 4 channels (x, y, width, height) on the remap texture.

    Code (CSharp):
    1. int x = id % gridSize;
    2. int y = id / gridSize;
    3.  
    4. Rect rect = sprite.rect;
    5. rect.width /= atlas.width;
    6. rect.height /= atlas.height;
    7. rect.x /= atlas.width;
    8. rect.y /= atlas.height;
    9.  
    10. remap.SetPixel(x, y, new(rect.x, rect.y, rect.width, rect.height));
    11. remap.Apply();
    Faces can now be assigned sprites that display on the die like a normal texture. Now that you can assign faces, you also might want to be able to retrieve them. We can preprocess the mesh by looping through its vertices, converting their secondary UV coordinates to grid-space, caching the normal for each grid, and then averaging them - you get the average direction of each face in local space.

    Code (CSharp):
    1. List<Vector2> uvs = new();
    2. List<Vector3> normals = new();
    3. mesh.GetNormals(normals);
    4. mesh.GetUVs(uvChannel, uvs);
    5. Dictionary<int, List<Vector3>> grids = new();
    6.  
    7. for (int i = 0; i < mesh.vertexCount; i++)
    8. {
    9.     Vector3 normal = normals[i];
    10.     Vector2 uv = uvs[i];
    11.    
    12.     int gridIndex = (int)(Mathf.Floor(uv.x * atlasSize) + Mathf.Floor((1f - uv.y) * atlasSize) * atlasSize);
    13.     if (!grids.TryGetValue(gridIndex, out var grid))
    14.     {
    15.         grid = new();
    16.         grids.Add(gridIndex, grid);
    17.     }
    18.    
    19.     grid.Add(normal);
    20. }
    21.  
    22. foreach (var (id, grid) in grids)
    23. {
    24.     Vector3 average = grid.Aggregate(Vector3.zero, (current, normal) => current + normal);
    25.     average /= grid.Count;
    26.     average.Normalize();
    27.    
    28.     _normals.Add(id, average);
    29. }
    30.  
    This can be used to find which face is aligned, or to align the face, along an arbitrary axis. Since the preprocessing was done in local space, you would want to transform world space directions into your die's local space.

    Code (CSharp):
    1. float bestDot = 0f;
    2. int bestId = -1;
    3.  
    4. foreach (var (id, _normal) in _normals)
    5. {
    6.     float dot = Vector3.Dot(_normal, normal);
    7.     if (bestId != -1 && dot < bestDot) continue;
    8.    
    9.     bestId = id;
    10.     bestDot = dot;
    11. }
    12.  
    13. return bestId;
    14.  
    There's obviously a lot more you can do with this. We originally randomized the faces but had some problems with the face mapping. Limited on time, we kept the faces predefined on a per-die basis.

    Conclusion
    In principle, this method seems over complicated, but in practice, it's rather quite simple. Whenever approaching a problem, take a few minutes to break down the problem into steps and visualize the solutions for each step. Initially, I knew I wanted to use a custom shader, but how would it work? The first idea that came to mind was to create an individual property for each face and assign it values manually, but as I walked that idea it started creating problems of its own. So, I took a step back, and came up with a more more autonomous method that was easier to write and deploy.
     
    Kurt-Dekker and RadRedPanda like this.
  2. RadRedPanda

    RadRedPanda

    Joined:
    May 9, 2018
    Posts:
    1,647
    Interesting! My immediate thought would have been to just smash 6 quads together and put its own texture on each of them, especially with the pressure of time looming over me. Best of luck!
     
    jameslroll likes this.
  3. passerbycmc

    passerbycmc

    Joined:
    Feb 12, 2015
    Posts:
    1,741
    would just give each side of the dice its own material, or just shove a quad over each face, that has its own material just where the decal for the symbol should be and make sure your symbols are otherwise transparent.
     
  4. jameslroll

    jameslroll

    Joined:
    Aug 26, 2014
    Posts:
    3
    With each material, you're adding another draw call. If you have multiple dice being shown with differing faces, it can gradually become expensive. One might think "a few extra draw calls aren't a big deal," until your scene begins to grow and suddenly "a few" becomes "too many."

    You also retain bump maps from the base surface, only changing the color to match the sprite. The sprite also contours with the mesh, which is only noticeable on the beveled edges, but might be more-so on more detailed meshes (like D-8+ die, which we were going to do until cutting it out). There might be ways to imitate it but I feel like they would just be more arduous than this method.

    It might not be a big deal for a game jam, but I was more fascinated with the concept. It only took me like 3 hours to make and I, or somebody else, might find it useful in the future.
     
    Kurt-Dekker likes this.
  5. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    38,742
    It's another awesome slice of how you can do complex things easily in Unity3D.

    You should throw it in a blank project and upload it as a git repo! I do this with several of my little codelets, and one of them is MakeGeo, which has lots of procgen in it. Who knows, people might fork and extend it and send you back the improvements!

    MakeGeo is presently hosted at these locations:

    https://bitbucket.org/kurtdekker/makegeo

    https://github.com/kurtdekker/makegeo

    https://gitlab.com/kurtdekker/makegeo

    https://sourceforge.net/p/makegeo

    The other obvious cool thing about a git repo is that you can trivially improve and extend it, and your various posts pointing to it automatically get all those improvements from the repo.
     
    jameslroll likes this.