Code/PixelArmsStyle.cs
using Sandbox;

namespace Marionette;

// The knobs materials/pixel_arms.vmat reads, in one place, so something other than the in-game
// viewmodel can wear the same look -- the rig editor's viewport uses it to pose against the arms
// as they actually appear rather than against a white mannequin.
//
// TWO THINGS ABOUT THIS MATERIAL ARE NOT OPTIONAL, both learned the hard way in the game this
// shader came from:
//
//   - It goes on as a WHOLE-MODEL MaterialOverride, not per-slot. That's what keeps the pixelation
//     scoped to this one renderer instead of the whole screen.
//
//   - A MaterialOverride cannot reach the model's own textures. Set it and nothing else and the
//     arms come out as flat-colour mannequin, which reads as a broken shader rather than as pixel
//     art. The albedo has to be handed over by hand as an attribute -- that's what ColorTexture is
//     for, and ResolveSkinTexture finds the usual one.
//
// This is sample content - the rig editor uses it so the included arms can be posed as they
// actually look rather than as a white mannequin. Nothing in the tool requires it.
public sealed class PixelArmsStyle
{
	// The generated albedo the stock first-person arms use. The path is ugly because the content
	// compiler mangles the source png name with a hash, but it's stable for a given package
	// version. Find the current one by searching "first_person" in the asset browser -- it's the
	// one ending _color.
	public const string DefaultSkinTexturePath =
		"models/first_person/materials/v_first_person_arms_human_color_png_5762368.generated.vtex";

	public const string DefaultSkinMaterialPath =
		"models/first_person/materials/v_first_person_arms_human_light.vmat";

	public Texture ColorTexture { get; init; }

	public float VertexSnap { get; init; } = 6f;
	public float TexelGrid { get; init; } = 180f;
	public float PixelBlock { get; init; } = 0f;
	public float ObjectSnap { get; init; } = 0f;
	public float FlatShade { get; init; } = 0f;
	public float ColorSteps { get; init; } = 0f;
	public float ColorDepth { get; init; } = 0f;
	public float MipLevel { get; init; } = 5f;
	public Color PixelColor { get; init; } = new( 0.76f, 0.66f, 0.58f );
	public float Roughness { get; init; } = 0.85f;
	public float Metalness { get; init; } = 0f;

	// Self-lit by default, which is what makes this usable in a bare editor scene - the look
	// doesn't depend on whatever lighting happens to be set up around it.
	public bool SelfLit { get; init; } = true;
	public Vector3 LightDirection { get; init; } = new( -0.4f, -0.6f, 0.7f );
	public float Ambient { get; init; } = 0.55f;
	public float Diffuse { get; init; } = 0.75f;

	public bool DebugShowTexture { get; init; }

	/// <summary>Which sign of object-space y to discard, for hiding one arm. 0 keeps both, which
	/// is what the rig editor wants - it's posing the whole skeleton, not framing a shot.</summary>
	public float HideSide { get; init; }

	/// <summary>The vertex snap works in screen pixels, so the shader needs the resolution in the
	/// vertex stage where the engine's viewport globals aren't dependable. In a tool window this is
	/// the widget's size, not Screen.Size.</summary>
	public Vector2 ScreenSize { get; init; } = new( 1920, 1080 );

	public static Material LoadMaterial() => Material.Load( "materials/pixel_arms.vmat" );

	/// <summary>The arms' albedo, so the override samples real skin instead of rendering a
	/// mannequin. Named outright rather than discovered - FirstTexture returns the wrong map on
	/// this material, and flat-coloured arms are a much better failure than red ones.</summary>
	public static Texture ResolveSkinTexture(
		string texturePath = DefaultSkinTexturePath,
		string materialPath = DefaultSkinMaterialPath )
	{
		if ( !string.IsNullOrWhiteSpace( texturePath ) && Texture.Load( texturePath, false ) is { } direct )
			return direct;

		if ( string.IsNullOrWhiteSpace( materialPath ) || Material.Load( materialPath ) is not { } material )
			return null;

		return material.GetTexture( "g_tColor" )
			?? material.GetTexture( "Color" )
			?? material.GetTexture( "g_tAlbedo" );
	}

	public void ApplyTo( SceneObject so )
	{
		if ( !so.IsValid() )
			return;

		if ( ColorTexture is not null )
			so.Attributes.Set( "ArmColorTex", ColorTexture );

		so.Attributes.Set( "ArmHasTex", ColorTexture is not null ? 1f : 0f );
		so.Attributes.Set( "ArmPixelColor", new Vector4( PixelColor.r, PixelColor.g, PixelColor.b, PixelColor.a ) );
		so.Attributes.Set( "ArmPixelBlock", PixelBlock );
		so.Attributes.Set( "ArmTexelGrid", TexelGrid );
		so.Attributes.Set( "ArmVertSnap", VertexSnap );
		so.Attributes.Set( "ArmPixelSize", ObjectSnap );
		so.Attributes.Set( "ArmScreenSize", ScreenSize );
		so.Attributes.Set( "ArmFlatShade", FlatShade );
		so.Attributes.Set( "ArmColorSteps", ColorSteps );
		so.Attributes.Set( "ArmRoughness", Roughness );
		so.Attributes.Set( "ArmMetalness", Metalness );
		so.Attributes.Set( "ArmHideSide", HideSide );
		so.Attributes.Set( "ArmMip", MipLevel );
		so.Attributes.Set( "ArmColorDepth", ColorDepth );
		so.Attributes.Set( "ArmDebug", DebugShowTexture ? 1f : 0f );
		so.Attributes.Set( "ArmSelfLit", SelfLit ? 1f : 0f );
		so.Attributes.Set( "ArmLightDir", LightDirection.Normal );
		so.Attributes.Set( "ArmAmbient", Ambient );
		so.Attributes.Set( "ArmDiffuse", Diffuse );
	}
}