Editor/WeaponImporter/Core/Generation/MaterialWriter.cs
#nullable enable annotations
using System.Globalization;
using System.Numerics;
using System.Text;
using WeaponImporter.Core.Geometry;
namespace WeaponImporter.Core.Generation;
/// <summary>A generated material: vmat text plus the texture files the caller must produce.</summary>
public sealed class MaterialOutput
{
/// <summary>Asset-relative path of the vmat (forward slashes), e.g. "weapons/ak74/materials/ak74m.vmat".</summary>
public required string VmatPath { get; init; }
public required string VmatText { get; init; }
/// <summary>
/// Texture files referenced by the vmat. <c>Channel == All</c>: copy / extract the source as
/// is. Any other channel: decode the source image and write that single channel as a
/// grayscale PNG (the editor side does this; Core has no image decoder).
/// </summary>
public required List<(string RelativePath, TextureRef Source, TextureChannel Channel)> Textures { get; init; }
}
/// <summary>Writes s&box <c>complex.shader</c> vmats for <see cref="MaterialInfo"/>.</summary>
public static class MaterialWriter
{
public const string GeneratedMarker = "// Generated by Weapon Importer. Delete this line to keep your edits on reimport.";
public const string DefaultColor = "materials/default/default_color.tga";
public const string DefaultNormal = "materials/default/default_normal.tga";
public const string DefaultRough = "materials/default/default_rough.tga";
/// <param name="materialsFolderRelative">Asset-relative folder, e.g. "weapons/ak74/materials".</param>
/// <param name="safeName">File-safe material name (e.g. the DMX faceSet name).</param>
public static MaterialOutput Write(MaterialInfo m, string materialsFolderRelative, string safeName)
{
ArgumentNullException.ThrowIfNull(m);
var folder = NormalizeFolder(materialsFolderRelative);
var name = DmxNames.Material(string.IsNullOrWhiteSpace(safeName) ? m.Name : safeName);
var textures = new List<(string RelativePath, TextureRef Source, TextureChannel Channel)>();
string Add(TextureRef source, TextureChannel channel, string suffix)
{
var ext = channel == TextureChannel.All ? SafeExtension(source.Extension) : ".png";
var path = Join(folder, $"{name}_{suffix}{ext}");
if (!textures.Any(t => t.RelativePath == path))
textures.Add((path, source, channel));
return path;
}
// Scalar maps: explicit channel (glTF occlusion = R) or glTF packing (G rough, B metal).
static TextureChannel ScalarChannel(TextureRef t, TextureChannel packed)
=> t.Channel != TextureChannel.All ? t.Channel : t.Packed ? packed : TextureChannel.All;
var sb = new StringBuilder();
void Line(string s) => sb.Append(s).Append('\n');
void Key(string key, string value) => Line($"\t{key} \"{value}\"");
Line(GeneratedMarker);
Line("Layer0");
Line("{");
Line("\tshader \"shaders/complex.shader\"");
Line("");
// Feature flags first (vmat convention).
var alphaTest = m.AlphaCutoff is not null;
var translucent = !alphaTest && m.Translucent;
if (alphaTest)
Key("F_ALPHA_TEST", "1");
if (translucent)
Key("F_TRANSLUCENT", "1");
if (m.DoubleSided)
Key("F_RENDER_BACKFACES", "1");
if (m.MetalnessTexture is not null)
Key("F_METALNESS_TEXTURE", "1");
if (m.EmissiveTexture is not null)
Key("F_SELF_ILLUM", "1");
if (alphaTest || translucent || m.DoubleSided || m.MetalnessTexture is not null || m.EmissiveTexture is not null)
Line("");
// Colour.
Key("TextureColor", m.BaseColorTexture is { } color ? Add(color, TextureChannel.All, "color") : DefaultColor);
var tint = m.BaseColor;
Key("g_vColorTint", $"[{F(tint.X)} {F(tint.Y)} {F(tint.Z)} 1.000000]");
// Opacity.
if (alphaTest || translucent)
{
string? translucency = null;
if (m.OpacityTexture is { } opacity)
translucency = Add(opacity, opacity.Channel, "trans");
else if (m.BaseColorTexture is { } baseTex)
translucency = Add(baseTex, TextureChannel.A, "trans");
if (translucency is not null)
Key("TextureTranslucency", translucency);
if (alphaTest)
Key("g_flAlphaTestReference", F(m.AlphaCutoff ?? 0.5f));
else if (translucency is null)
Key("g_flOpacityScale", F(m.BaseColor.W));
}
// Normal.
Key("TextureNormal", m.NormalTexture is { } normal ? Add(normal, TextureChannel.All, "normal") : DefaultNormal);
// Roughness.
if (m.RoughnessTexture is { } rough)
{
Key("TextureRoughness", Add(rough, ScalarChannel(rough, TextureChannel.G), "rough"));
Key("g_flRoughnessScaleFactor", F(m.Roughness));
}
else
{
Key("TextureRoughness", DefaultRough);
Key("g_flRoughnessScaleFactor", F(m.Roughness));
}
// Metalness.
if (m.MetalnessTexture is { } metal)
Key("TextureMetalness", Add(metal, ScalarChannel(metal, TextureChannel.B), "metal"));
else
Key("g_flMetalness", F(m.Metalness));
// Ambient occlusion.
if (m.AmbientOcclusionTexture is { } ao)
Key("TextureAmbientOcclusion", Add(ao, ScalarChannel(ao, TextureChannel.R), "ao"));
// Self illumination.
if (m.EmissiveTexture is { } emissive)
Key("TextureSelfIllumMask", Add(emissive, TextureChannel.All, "selfillum"));
Line("}");
return new MaterialOutput { VmatPath = Join(folder, name + ".vmat"), VmatText = sb.ToString(), Textures = textures };
}
/// <summary>
/// Writes the vmat and every whole-image texture under <paramref name="assetsRoot"/> (an
/// absolute folder). Existing vmats whose first line is no longer <see cref="GeneratedMarker"/>
/// are left alone. Returns the channel extractions that still need an image decoder.
/// </summary>
public static List<(string AbsolutePath, TextureRef Source, TextureChannel Channel)> WriteFiles(MaterialOutput output, string assetsRoot)
{
var pending = new List<(string, TextureRef, TextureChannel)>();
var vmat = Absolute(assetsRoot, output.VmatPath);
Directory.CreateDirectory(Path.GetDirectoryName(vmat) ?? assetsRoot);
if (!File.Exists(vmat) || IsGenerated(File.ReadAllText(vmat)))
File.WriteAllText(vmat, output.VmatText);
foreach (var (relative, source, channel) in output.Textures)
{
var path = Absolute(assetsRoot, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? assetsRoot);
if (channel != TextureChannel.All)
{
pending.Add((path, source, channel));
continue;
}
if (source.FilePath is { } file)
{
if (!string.Equals(Path.GetFullPath(file), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase))
File.Copy(file, path, overwrite: true);
}
else if (source.Bytes is { } bytes)
{
File.WriteAllBytes(path, bytes);
}
}
return pending;
}
/// <summary>Whether a vmat still carries the generated marker (safe to overwrite).</summary>
public static bool IsGenerated(string vmatText)
{
var firstLine = vmatText.Split('\n')[0].TrimEnd('\r');
return firstLine == GeneratedMarker;
}
private static string Absolute(string root, string relative)
=> Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));
private static string NormalizeFolder(string folder)
{
var f = (folder ?? "").Replace('\\', '/').Trim().Trim('/');
while (f.Contains("//"))
f = f.Replace("//", "/");
return f.ToLowerInvariant();
}
private static string Join(string folder, string file) => folder.Length == 0 ? file.ToLowerInvariant() : $"{folder}/{file.ToLowerInvariant()}";
private static string SafeExtension(string ext)
{
var e = (ext ?? "").ToLowerInvariant();
return e is ".png" or ".jpg" or ".jpeg" or ".tga" or ".psd" or ".tif" or ".tiff" or ".bmp" or ".exr" or ".webp" ? e : ".png";
}
private static string F(float v) => (float.IsFinite(v) ? v : 0f).ToString("0.000000", CultureInfo.InvariantCulture);
}