Effigy/FabricMaterial.cs

Procedural fabric material generator for garments. It produces tiled color, normal and roughness maps from a small set of presets (jersey, denim, satin, etc.), computes per-pixel height/normal from weave functions and noise, and emits a .vmat material text with appropriate shading switches.

File Access
using System;
using System.Globalization;

namespace Effigy;

/// <summary>
/// Real fabric for a garment: a tiling colour, normal and roughness set computed from a weave,
/// and a Complex .vmat with the switches the store's own items use for that kind of cloth.
///
/// WHY PROCEDURAL. A garment with the default grey material is not a shirt yet, and asking for a
/// texture is asking for a second tool and a week of learning it. Every fabric people actually
/// put on a character is a small repeating structure - a knit's loops, a twill's diagonal, cord's
/// ridges, plaid's stripes - and small repeating structures are what a few lines of maths make
/// well. A 512 tile at a foot to a UV unit (see GarmentFit.LimbUVs) is sharp up close and repeats
/// without a seam, which is the whole job of a fabric texture.
///
/// THE SWITCHES MATTER MORE THAN THE PIXELS. Complex's cloth shading is what makes fleece and
/// velvet look soft, and anisotropic gloss is what makes satin sheen along the weave; without
/// them a perfect texture still reads as painted plastic. See CLOTHING.md for which shipped item
/// uses which. Everything here is a starting point to recolour and rescale, not a claim about any
/// real mill's cloth.
/// </summary>
public static class FabricMaterial
{
	public const int Size = 512;

	public static readonly string[] Presets =
	{
		"Jersey", "Rib knit", "Fleece", "Denim", "Corduroy", "Leather", "Satin", "Canvas", "Plaid", "Gingham", "Quilted",
	};

	/// <summary>Which Complex feature a preset leans on: none, cloth shading, or anisotropic gloss.</summary>
	public static string ShadingFor( string preset ) => preset switch
	{
		"Fleece" or "Corduroy" or "Rib knit" or "Jersey" => "cloth",
		"Satin" => "anisotropic",
		_ => "",
	};

	/// <summary>Default roughness, 0 glossy to 1 matte. Leather and satin shine; knits do not.</summary>
	public static float RoughnessFor( string preset ) => preset switch
	{
		"Leather" => 0.45f,
		"Satin" => 0.35f,
		"Denim" or "Canvas" => 0.85f,
		_ => 0.92f,
	};

	/// <summary>
	/// The maps: colour and normal as RGBA, roughness as RGBA grey. <paramref name="colour"/> is
	/// the cloth, <paramref name="accent"/> the second thread of a plaid or gingham. <paramref
	/// name="scale"/> is how many repeats across the tile - more is a finer weave.
	/// </summary>
	public static (byte[] Color, byte[] Normal, byte[] Roughness) Maps( string preset, Vec3 colour, Vec3 accent,
		float scale, float wear, int seed = 1 )
	{
		var size = Size;
		var color = new byte[size * size * 4];
		var normal = new byte[size * size * 4];
		var rough = new byte[size * size * 4];
		var height = new float[size * size];
		var tint = new float[size * size * 3];
		var baseRough = RoughnessFor( preset );

		// A whole number of repeats, or the tile would not wrap.
		var repeats = Math.Max( 1, (int)MathF.Round( scale ) );
		scale = repeats;

		for ( var y = 0; y < size; y++ )
		{
			for ( var x = 0; x < size; x++ )
			{
				// Periodic in [0,1) so the tile repeats without a seam whatever the scale.
				var u = x / (float)size * scale;
				var v = y / (float)size * scale;
				var i = y * size + x;
				var (h, mix) = Weave( preset, u, v, seed, repeats );

				// Wear: a slow blotch of lighter, rougher cloth, the way knees and elbows go.
				var w = wear * (Noise( x / 64f, y / 64f, seed + 11, size / 64 ) * 0.5f + 0.5f);
				height[i] = h;

				var c = colour * (1f - mix) + accent * mix;
				c = c * (1f - w * 0.25f) + new Vec3( 1f, 1f, 1f ) * (w * 0.25f);

				// Thread-level shade: deeper in the weave is darker.
				var shade = 0.82f + 0.18f * h;
				tint[i * 3] = c.x * shade;
				tint[i * 3 + 1] = c.y * shade;
				tint[i * 3 + 2] = c.z * shade;

				var r = Math.Clamp( baseRough + (0.5f - h) * 0.15f + w * 0.1f, 0.05f, 1f );
				var rb = Byte( r );
				rough[i * 4] = rb;
				rough[i * 4 + 1] = rb;
				rough[i * 4 + 2] = rb;
				rough[i * 4 + 3] = 255;
			}
		}

		// Normals from the height, wrapped, so the tile's edges match.
		var strength = preset is "Leather" or "Satin" ? 1.5f : 4f;

		for ( var y = 0; y < size; y++ )
		{
			for ( var x = 0; x < size; x++ )
			{
				var i = y * size + x;
				var dx = height[y * size + (x + 1) % size] - height[y * size + (x + size - 1) % size];
				var dy = height[((y + 1) % size) * size + x] - height[((y + size - 1) % size) * size + x];
				var n = new Vec3( -dx * strength, -dy * strength, 1f ).Normal;

				normal[i * 4] = Byte( n.x * 0.5f + 0.5f );
				normal[i * 4 + 1] = Byte( n.y * 0.5f + 0.5f );
				normal[i * 4 + 2] = Byte( n.z * 0.5f + 0.5f );
				normal[i * 4 + 3] = 255;

				color[i * 4] = Byte( tint[i * 3] );
				color[i * 4 + 1] = Byte( tint[i * 3 + 1] );
				color[i * 4 + 2] = Byte( tint[i * 3 + 2] );
				color[i * 4 + 3] = 255;
			}
		}

		return (color, normal, rough);
	}

	/// <summary>Height 0..1 of the weave at a periodic (u, v), and how much of the accent colour
	/// is there. Each preset is a different small structure.</summary>
	static (float Height, float Mix) Weave( string preset, float u, float v, int seed, int repeats )
	{
		var fu = u - MathF.Floor( u );
		var fv = v - MathF.Floor( v );

		switch ( preset )
		{
			case "Jersey":
			{
				// Interlocking V loops: columns of stitches, each row offset.
				var col = MathF.Floor( u * 2f );
				var row = fv + (((int)col & 1) == 0 ? 0f : 0.5f);
				row -= MathF.Floor( row );
				var arc = MathF.Sin( row * MathF.PI );
				var side = MathF.Abs( (u * 2f - MathF.Floor( u * 2f )) - 0.5f ) * 2f;
				return (Math.Clamp( arc * (1f - side * 0.6f), 0f, 1f ), 0f);
			}

			case "Rib knit":
			{
				// Alternating raised wales, twice the loop pitch.
				var wale = 0.5f + 0.5f * MathF.Cos( u * MathF.PI * 2f );
				var loop = 0.5f + 0.5f * MathF.Cos( v * MathF.PI * 4f );
				return (wale * 0.75f + loop * 0.25f, 0f);
			}

			case "Fleece":
			{
				// Fine matted pile: high-frequency noise, no structure to speak of.
				var n = Noise( u * 8f, v * 8f, seed, repeats * 8 ) * 0.6f + Noise( u * 32f, v * 32f, seed + 3, repeats * 32 ) * 0.4f;
				return (n * 0.5f + 0.5f, 0f);
			}

			case "Denim":
			{
				// 3/1 twill: the diagonal that makes denim denim, with the white weft showing in
				// the troughs.
				var d = (u + v) * 4f;
				var t = d - MathF.Floor( d );
				var ridge = t < 0.75f ? 1f - MathF.Abs( t / 0.75f - 0.5f ) * 2f : 0f;
				var thread = 0.5f + 0.5f * MathF.Cos( u * MathF.PI * 16f );
				return (ridge * 0.8f + thread * 0.2f, (1f - ridge) * 0.35f);
			}

			case "Corduroy":
			{
				// Wide soft ridges along v, with a hint of the pile's fuzz.
				var ridge = 0.5f + 0.5f * MathF.Cos( u * MathF.PI * 2f );
				return (MathF.Pow( ridge, 0.7f ) * 0.9f + Noise( u * 30f, v * 30f, seed, repeats * 30 ) * 0.1f, 0f);
			}

			case "Leather":
			{
				// Grain: cells of noise with creases between them.
				var cell = Noise( u * 6f, v * 6f, seed, repeats * 6 ) * 0.5f + Noise( u * 24f, v * 24f, seed + 5, repeats * 24 ) * 0.5f;
				var crease = MathF.Abs( Noise( u * 3f, v * 3f, seed + 9, repeats * 3 ) );
				return (Math.Clamp( 0.55f + cell * 0.3f - crease * 0.25f, 0f, 1f ), 0f);
			}

			case "Satin":
			{
				// Almost flat; long floats along u give the anisotropic gloss its direction.
				var f = 0.5f + 0.5f * MathF.Cos( v * MathF.PI * 24f );
				return (0.5f + f * 0.08f, 0f);
			}

			case "Canvas":
			{
				// Plain weave, coarse: over-under both ways.
				var a = MathF.Floor( u * 4f ) + MathF.Floor( v * 4f );
				var over = ((int)a & 1) == 0;
				var warp = 0.5f + 0.5f * MathF.Cos( (u * 4f - MathF.Floor( u * 4f ) - 0.5f) * MathF.PI * 2f );
				var weft = 0.5f + 0.5f * MathF.Cos( (v * 4f - MathF.Floor( v * 4f ) - 0.5f) * MathF.PI * 2f );
				return (over ? 0.5f + warp * 0.5f : 0.5f + weft * 0.5f, 0f);
			}

			case "Plaid":
			{
				// Two colours in wide bands both ways, with a plain weave under them.
				var bandU = fu < 0.5f ? 1f : 0f;
				var bandV = fv < 0.5f ? 1f : 0f;
				var mix = bandU + bandV > 1.5f ? 1f : bandU + bandV > 0.5f ? 0.5f : 0f;
				var a = MathF.Floor( u * 16f ) + MathF.Floor( v * 16f );
				var weave = ((int)a & 1) == 0 ? 0.6f : 0.4f;
				return (weave, mix);
			}

			case "Gingham":
			{
				// Checks: the overlap of a stripe both ways is the full colour, one is half.
				var bandU = fu < 0.5f ? 1f : 0f;
				var bandV = fv < 0.5f ? 1f : 0f;
				var mix = (bandU + bandV) * 0.5f;
				var a = MathF.Floor( u * 24f ) + MathF.Floor( v * 24f );
				var weave = ((int)a & 1) == 0 ? 0.6f : 0.4f;
				return (weave, mix);
			}

			case "Quilted":
			{
				// Diamond puffs with stitched seams.
				var du = MathF.Abs( fu - 0.5f );
				var dv = MathF.Abs( fv - 0.5f );
				var puff = 1f - Math.Clamp( (du + dv) * 2f, 0f, 1f );
				return (MathF.Sqrt( puff ) * 0.9f + Noise( u * 20f, v * 20f, seed, repeats * 20 ) * 0.1f, 0f);
			}
		}

		return (0.5f, 0f);
	}

	/// <summary>The .vmat, with the switch the preset's shading asks for.</summary>
	public static string VmatSource( string preset, string colorPng, string normalPng, string roughPng )
	{
		static string F( float v ) => v.ToString( "0.000", CultureInfo.InvariantCulture );

		var shading = ShadingFor( preset );
		var extra = shading switch
		{
			"cloth" => "\tF_CLOTH_SHADING 1\n\tg_flClothFuzz \"0.500\"\n",
			"anisotropic" => "\tF_ANISOTROPIC_GLOSS 1\n\tg_flAnisotropicGloss \"0.700\"\n",
			_ => "",
		};

		return
			"// THIS FILE IS AUTO-GENERATED by an Effigy Fabric feature. Edit the feature, not this file.\n" +
			"Layer0\n" +
			"{\n" +
			"\tshader \"complex.vfx\"\n" +
			extra +
			"\n" +
			"\t//---- Color ----\n" +
			"\tg_flModelTintAmount \"1.000\"\n" +
			"\tg_vColorTint \"[1.000000 1.000000 1.000000 0.000000]\"\n" +
			$"\tTextureColor \"{colorPng}\"\n" +
			"\n" +
			"\t//---- Metalness ----\n" +
			"\tg_flMetalness \"0.000\"\n" +
			"\n" +
			"\t//---- Normal ----\n" +
			$"\tTextureNormal \"{normalPng}\"\n" +
			"\n" +
			"\t//---- Roughness ----\n" +
			$"\tTextureRoughness \"{roughPng}\"\n" +
			$"\tg_flRoughnessScaleFactor \"{F( 1f )}\"\n" +
			"\n" +
			"\t//---- Texture Coordinates ----\n" +
			"\tg_nScaleTexCoordUByModelScaleAxis \"0\"\n" +
			"\tg_nScaleTexCoordVByModelScaleAxis \"0\"\n" +
			"\tg_vTexCoordOffset \"[0.000 0.000]\"\n" +
			"\tg_vTexCoordScale \"[1.000 1.000]\"\n" +
			"\tg_vTexCoordScrollSpeed \"[0.000 0.000]\"\n" +
			"}\n";
	}

	static byte Byte( float v ) => (byte)Math.Clamp( (int)MathF.Round( v * 255f ), 0, 255 );

	/// <summary>Smooth value noise in -1..1 on an integer lattice that repeats every
	/// <paramref name="period"/> units, so a tile sampled over a whole number of periods wraps.</summary>
	static float Noise( float x, float y, int seed, int period )
	{
		var x0 = (int)MathF.Floor( x );
		var y0 = (int)MathF.Floor( y );
		period = Math.Max( 1, period );
		var fx = x - x0;
		var fy = y - y0;
		var sx = fx * fx * (3f - 2f * fx);
		var sy = fy * fy * (3f - 2f * fy);

		float H( int a, int b )
		{
			a = ((a % period) + period) % period;
			b = ((b % period) + period) % period;

			unchecked
			{
				var h = a * 374761393 + b * 668265263 + seed * 1274126177;
				h = (h ^ (h >> 13)) * 1274126177;
				return ((h ^ (h >> 16)) & 0xffff) / 32767.5f - 1f;
			}
		}

		var a = H( x0, y0 );
		var b = H( x0 + 1, y0 );
		var c = H( x0, y0 + 1 );
		var d = H( x0 + 1, y0 + 1 );

		return (a + (b - a) * sx) * (1f - sy) + (c + (d - c) * sx) * sy;
	}
}