⬆️ ⬇️

Making the game right before your eyes - part 4: Shader for fade in on the palette (a la NES)

Today I will talk about the implementation of the shader, which allows you to fade in / out the palette, as was done in old NES games, etc.



The bottom line is that with a limited palette of colors, it was impossible to gradually darken (or, on the contrary, remove from the darkness) the picture, because just simply did not exist the right colors in the palette. And this was solved by using different colors, which are perceived as darker. That is, you need to make a fade in a yellow object, and in the palette there are no dark yellow hues - so you have to first make the object blue (looks dark), then red, and so on.



Below I will show you what the final version of the written shader looks like:

')





I’ll just make a reservation - whether or not to use such a shader in our game, we haven’t decided yet. So how it looks on modern pixel art with a lot of colors is a bit controversial.



So, for the beginning we will write a shader blank:

shader
Shader "Custom/Palette Shader" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} } SubShader { Pass { ZTest Always Cull Off ZWrite Off Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma fragmentoption ARB_precision_hint_fastest #include "UnityCG.cginc" #pragma target 3.0 struct v2f { float4 pos : POSITION; float2 uv : TEXCOORD0; }; uniform sampler2D _MainTex; v2f vert(appdata_img v) { v2f o; o.pos = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord); return o; } half4 frag(v2f i): COLOR { half4 color = tex2D(_MainTex, i.uv); //   ,   half4 rc = color; return rc; } ENDCG } } FallBack "Diffuse" } 
c #
 using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class PaletteShader : MonoBehaviour { public Shader shader; private Material _material; //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// protected Material material { get { if (_material == null) { _material = new Material(shader); _material.hideFlags = HideFlags.HideAndDontSave; } return _material; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (shader == null) return; Material mat = material; Graphics.Blit(source, destination, mat); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// void OnDisable() { if (_material) DestroyImmediate(_material); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// } 




And now let's think ...



For a start - a little theory. As I said above, colors are perceived differently. Blue is perceived as the darkest, etc. In general, if you take the table of the TV settings and look at it on the B / W TV - then it will be ordered from light to dark:







Describes such a color conversion in b / w magic formula: R*0.21 + G*0.72 + B*0.07 . Will call this parameter "brightness."



The shader will work as follows: it will take the original image, change its brightness (lower), and then try to find the color from the available palette that would be the closest in brightness. That is, in fact, the shader is divided into two parts: 1) lower the brightness and 2) select the color from the palette.



With the lowering of the brightness, everything is simple - we will primitively multiply the color by the coefficient. But finding the nearest color in the palette is more difficult.



Those familiar with shaders understand that any cycle in a shader is tantamount to suicide. So going through the palette in search of the right color for each pixel is a bad idea. How to be?



The solution is simple and elegant - to create a texture that would serve as a color converter. And very well that there is such a thing as three-dimensional textures. That is, we take and calculate in advance the table of converting the original color into the color index in the palette. And even better - immediately in the final color. In such a texture, the companent R / G / B values ​​will be arranged along three axes, and the color of the pixel at this point will be our resulting color. It's simple! It remains only to create such a texture.



Of course, for accurate color conversion, one would have to create a monstrous texture, where the dimension along each axis would correspond to the number of gradations of each component. That is 256x256x256. But in our case, accuracy is not important to us, since we are committed to reducing the color depth and reducing all colors to several colors in the palette.



So, first let's create a palette and immediately for each color we will remember its brightness:



 const int depth = 3; // -      const float f_depth = 1.0f / (1.0f * depth - 1.0f); Color[] palette = new Color[depth*depth*depth]; float[] palette_grey = new float[depth*depth*depth]; //     for (int r = 0; r < depth; r++) { for (int g = 0; g < depth; g++) { for (int b = 0; b < depth; b++) { Color c = new Color(r * f_depth / 2, g * f_depth, b * f_depth, 1); int n = r*depth*depth + g*depth + b; palette[n] = c; palette_grey[n] = cr*0.21f + cg*0.72f + cb*0.07f; } } } 


It is worth paying attention to the fact that I ended up sharing the R component by 2, because I did not like the fact that in the resulting palette, the color red was already very “bulging”.



And now - the most interesting. You need to create a 3D texture for conversion.

 const int dim = 16; // -      const float f_dim = 1.0f / (1.0f * dim - 1.0f); Texture3D tex = new Texture3D(dim, dim, dim, TextureFormat.RGB565, false); tex.filterMode = FilterMode.Point; //   ! tex.wrapMode = TextureWrapMode.Clamp; Color[] t = new Color[dim*dim*dim]; //    for (int r = 0; r < dim; r++) { for (int g = 0; g < dim; g++) { for (int b = 0; b < dim; b++) { float grey = (r * 0.21f + g * 0.72f + b * 0.07f) * f_dim; //         int idx = 0; float min_d = grey; for (int i = 1; i < palette_grey.Length; i++) { float d = Mathf.Abs(palette_grey[i] - grey); if (d < min_d) { min_d = d; idx = i; } } t[r * dim * dim + g * dim + b] = palette[idx]; //    } } } tex.SetPixels(t); tex.Apply(); 


Well, actually, it remains to write the shader itself, but everything is simple:

 half4 color = tex2D(_MainTex, i.uv); half4 rc = tex3D(_PaletteTex, color.rgb * _Br); float d = abs(Luminance(color) - Luminance(rc)); if ((d < 0.15) || (_Br == 1)) rc = color; return rc; 


Here it is worth paying attention to the line with if . The second condition is obviously - “if brightness == 1, then we return the original color intact.” But the first is a kind of condition that “when the color from the palette is quite close (within 15%) to the resulting one, then also leave the original color. This is done in order to reduce some unnecessary "chatter" of flowers. A kind of "snapping", if you please. And that is why you can see that some elements on our screen become their color before the final phase. Otherwise, they would not be of their own color to the last, but as close as possible from the palette. What would look bad for dark colors.



Actually, everything.



Final version:

shader
 Shader "Custom/Palette Shader" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} _Br("Brightness", Float) = 0 _PaletteTex ("Pelette texture", 3D) = "white" {} } SubShader { Pass { ZTest Always Cull Off ZWrite Off Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma fragmentoption ARB_precision_hint_fastest #include "UnityCG.cginc" #pragma target 3.0 struct v2f { float4 pos : POSITION; float2 uv : TEXCOORD0; }; uniform sampler2D _MainTex; uniform sampler3D _PaletteTex; uniform float _Br; v2f vert(appdata_img v) { v2f o; o.pos = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = MultiplyUV(UNITY_MATRIX_TEXTURE0, v.texcoord); return o; } half4 frag(v2f i): COLOR { half4 color = tex2D(_MainTex, i.uv); half4 rc = tex3D(_PaletteTex, color.rgb * _Br); float d = abs(Luminance(color) - Luminance(rc)); if ((d < 0.15) || (_Br == 1)) rc = color; return rc; } ENDCG } } FallBack "Diffuse" } 
c #
 using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class PaletteShader : MonoBehaviour { public Shader shader; private Material _material; [Range(0, 1)] public float brightness = 0.0f; [Range(0, 1)] public float random = 1f; private float _r = 0f; private Texture3D _tex; //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// protected Material material { get { if (_material == null) { _material = new Material(shader); _material.hideFlags = HideFlags.HideAndDontSave; } return _material; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private Texture3D GeneratePaletteTexture() { const int dim = 16; // -      const int depth = 3; // -      const float f_dim = 1.0f / (1.0f * dim - 1.0f); const float f_depth = 1.0f / (1.0f * depth - 1.0f); Texture3D tex = new Texture3D(dim, dim, dim, TextureFormat.RGB565, false); tex.filterMode = FilterMode.Point; tex.wrapMode = TextureWrapMode.Clamp; Color[] palette = new Color[depth*depth*depth]; float[] palette_grey = new float[depth*depth*depth]; //     for (int r = 0; r < depth; r++) { for (int g = 0; g < depth; g++) { for (int b = 0; b < depth; b++) { Color c = new Color(r * f_depth / 2, g * f_depth, b * f_depth, 1); int n = r*depth*depth + g*depth + b; palette[n] = c; palette_grey[n] = cr*0.21f + cg*0.72f + cb*0.07f; } } } Color[] t = new Color[dim*dim*dim]; //    for (int r = 0; r < dim; r++) { for (int g = 0; g < dim; g++) { for (int b = 0; b < dim; b++) { float grey = (r * 0.21f + g * 0.72f + b * 0.07f) * f_dim; //         int idx = 0; float min_d = grey; for (int i = 1; i < palette_grey.Length; i++) { float d = Mathf.Abs(palette_grey[i] - grey); if (d < min_d) { min_d = d; idx = i; } } t[r * dim * dim + g * dim + b] = palette[idx]; //    } } } tex.SetPixels(t); tex.Apply(); return tex; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (shader == null) return; Material mat = material; mat.SetFloat("_Br", brightness); if (_tex == null) _tex = GeneratePaletteTexture(); if (random != _r) { _r = random; _tex = GeneratePaletteTexture(); } mat.SetTexture("_PaletteTex", _tex); Graphics.Blit(source, destination, mat); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// void OnDisable() { if (_material) DestroyImmediate(_material); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// } 




It is also worth noting that in the code above I have entered such a parameter as „random“. This was done in order to have a simple opportunity to rebuild the color table on the fly and it was more convenient to select the parameters of the palette. That is, I changed the code that generated the palette and shifted the „random“ slider to make the game re-generate the palette.



All articles in the series:

  1. Idea, vision, choice of setting, platform, distribution model, etc.
  2. Shaders for styling images under the CRT / LCD
  3. We fasten the scripting language to Unity (UniLua)
  4. Shader for fade in on the palette (a la NES)
  5. Subtotal (prototype)
  6. Let's talk about the indie games
  7. 2D animations in Unity ("as in flash")
  8. Visual scripting of cut scenes in Unity (uScript)

Source: https://habr.com/ru/post/213253/



All Articles