mirror of
https://github.com/xivdev/Penumbra.git
synced 2026-02-12 19:14:38 +01:00
373 lines
16 KiB
C#
373 lines
16 KiB
C#
using Lumina.Data.Parsing;
|
|
using Penumbra.GameData.Files;
|
|
using SharpGLTF.Materials;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Advanced;
|
|
using SixLabors.ImageSharp.Formats.Png;
|
|
using SixLabors.ImageSharp.Memory;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using SixLabors.ImageSharp.Processing;
|
|
|
|
namespace Penumbra.Import.Models.Export;
|
|
|
|
using ImageSharpConfiguration = SixLabors.ImageSharp.Configuration;
|
|
|
|
public class MaterialExporter
|
|
{
|
|
public struct Material
|
|
{
|
|
public MtrlFile Mtrl;
|
|
public Dictionary<TextureUsage, Image<Rgba32>> Textures;
|
|
// variant?
|
|
}
|
|
|
|
public static MaterialBuilder Export(Material material, string name)
|
|
{
|
|
Penumbra.Log.Debug($"Exporting material \"{name}\".");
|
|
return material.Mtrl.ShaderPackage.Name switch
|
|
{
|
|
// NOTE: this isn't particularly precise to game behavior (it has some fade around high opacity), but good enough for now.
|
|
"character.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.MASK, 0.5f),
|
|
"characterglass.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.BLEND),
|
|
"hair.shpk" => BuildHair(material, name),
|
|
"iris.shpk" => BuildIris(material, name),
|
|
"skin.shpk" => BuildSkin(material, name),
|
|
_ => BuildFallback(material, name),
|
|
};
|
|
}
|
|
|
|
private static MaterialBuilder BuildCharacter(Material material, string name)
|
|
{
|
|
var table = material.Mtrl.Table;
|
|
|
|
// TODO: there's a few normal usages i should check, i think.
|
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
|
|
|
var operation = new ProcessCharacterNormalOperation(normal, table);
|
|
ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, normal.Bounds(), in operation);
|
|
|
|
Image baseColor = operation.BaseColor;
|
|
if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse))
|
|
{
|
|
MultiplyOperation.Execute(diffuse, operation.BaseColor);
|
|
baseColor = diffuse;
|
|
}
|
|
|
|
// TODO: what about the two specularmaps?
|
|
Image specular = operation.Specular;
|
|
if (material.Textures.TryGetValue(TextureUsage.SamplerSpecular, out var specularTexture))
|
|
{
|
|
MultiplyOperation.Execute(specularTexture, operation.Specular);
|
|
specular = specularTexture;
|
|
}
|
|
|
|
Image? occlusion = null;
|
|
if (material.Textures.TryGetValue(TextureUsage.SamplerMask, out var maskTexture))
|
|
{
|
|
// Extract the red channel for ambient occlusion.
|
|
maskTexture.Mutate(context => context.Filter(new ColorMatrix(
|
|
1f, 1f, 1f, 0f,
|
|
0f, 0f, 0f, 0f,
|
|
0f, 0f, 0f, 0f,
|
|
0f, 0f, 0f, 1f,
|
|
0f, 0f, 0f, 0f
|
|
)));
|
|
occlusion = maskTexture;
|
|
|
|
// TODO: handle other textures stored in the mask?
|
|
}
|
|
|
|
var materialBuilder = BuildSharedBase(material, name)
|
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
|
.WithNormal(BuildImage(operation.Normal, name, "normal"))
|
|
.WithSpecularColor(BuildImage(specular, name, "specular"))
|
|
.WithEmissive(BuildImage(operation.Emissive, name, "emissive"), Vector3.One, 1);
|
|
|
|
if (occlusion != null)
|
|
materialBuilder.WithOcclusion(BuildImage(occlusion, name, "occlusion"));
|
|
|
|
return materialBuilder;
|
|
}
|
|
|
|
// TODO: It feels a little silly to request the entire normal here when extrating the normal only needs some of the components.
|
|
// As a future refactor, it would be neat to accept a single-channel field here, and then do composition of other stuff later.
|
|
private readonly struct ProcessCharacterNormalOperation(Image<Rgba32> normal, MtrlFile.ColorTable table) : IRowOperation
|
|
{
|
|
public Image<Rgba32> Normal { get; private init; } = normal.Clone();
|
|
public Image<Rgba32> BaseColor { get; private init; } = new Image<Rgba32>(normal.Width, normal.Height);
|
|
public Image<Rgb24> Specular { get; private init; } = new Image<Rgb24>(normal.Width, normal.Height);
|
|
public Image<Rgb24> Emissive { get; private init; } = new Image<Rgb24>(normal.Width, normal.Height);
|
|
|
|
private Buffer2D<Rgba32> NormalBuffer => Normal.Frames.RootFrame.PixelBuffer;
|
|
private Buffer2D<Rgba32> BaseColorBuffer => BaseColor.Frames.RootFrame.PixelBuffer;
|
|
private Buffer2D<Rgb24> SpecularBuffer => Specular.Frames.RootFrame.PixelBuffer;
|
|
private Buffer2D<Rgb24> EmissiveBuffer => Emissive.Frames.RootFrame.PixelBuffer;
|
|
|
|
public void Invoke(int y)
|
|
{
|
|
var normalSpan = NormalBuffer.DangerousGetRowSpan(y);
|
|
var baseColorSpan = BaseColorBuffer.DangerousGetRowSpan(y);
|
|
var specularSpan = SpecularBuffer.DangerousGetRowSpan(y);
|
|
var emissiveSpan = EmissiveBuffer.DangerousGetRowSpan(y);
|
|
|
|
for (int x = 0; x < normalSpan.Length; x++)
|
|
{
|
|
ref var normalPixel = ref normalSpan[x];
|
|
|
|
// Table row data (.a)
|
|
var tableRow = GetTableRowIndices(normalPixel.A / 255f);
|
|
var prevRow = table[tableRow.Previous];
|
|
var nextRow = table[tableRow.Next];
|
|
|
|
// Base colour (table, .b)
|
|
var lerpedDiffuse = Vector3.Lerp(prevRow.Diffuse, nextRow.Diffuse, tableRow.Weight);
|
|
baseColorSpan[x].FromVector4(new Vector4(lerpedDiffuse, 1));
|
|
baseColorSpan[x].A = normalPixel.B;
|
|
|
|
// Specular (table)
|
|
var lerpedSpecularColor = Vector3.Lerp(prevRow.Specular, nextRow.Specular, tableRow.Weight);
|
|
specularSpan[x].FromVector4(new Vector4(lerpedSpecularColor, 1));
|
|
|
|
// Emissive (table)
|
|
var lerpedEmissive = Vector3.Lerp(prevRow.Emissive, nextRow.Emissive, tableRow.Weight);
|
|
emissiveSpan[x].FromVector4(new Vector4(lerpedEmissive, 1));
|
|
|
|
// Normal (.rg)
|
|
// TODO: we don't actually need alpha at all for normal, but _not_ using the existing rgba texture means I'll need a new one, with a new accessor. Think about it.
|
|
normalPixel.B = byte.MaxValue;
|
|
normalPixel.A = byte.MaxValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static TableRow GetTableRowIndices(float input)
|
|
{
|
|
// These calculations are ported from character.shpk.
|
|
var smoothed = MathF.Floor(((input * 7.5f) % 1.0f) * 2)
|
|
* (-input * 15 + MathF.Floor(input * 15 + 0.5f))
|
|
+ input * 15;
|
|
|
|
var stepped = MathF.Floor(smoothed + 0.5f);
|
|
|
|
return new TableRow
|
|
{
|
|
Stepped = (int)stepped,
|
|
Previous = (int)MathF.Floor(smoothed),
|
|
Next = (int)MathF.Ceiling(smoothed),
|
|
Weight = smoothed % 1,
|
|
};
|
|
}
|
|
|
|
private ref struct TableRow
|
|
{
|
|
public int Stepped;
|
|
public int Previous;
|
|
public int Next;
|
|
public float Weight;
|
|
}
|
|
|
|
private readonly struct MultiplyOperation
|
|
{
|
|
public static void Execute<TPixel1, TPixel2>(Image<TPixel1> target, Image<TPixel2> multiplier)
|
|
where TPixel1 : unmanaged, IPixel<TPixel1>
|
|
where TPixel2 : unmanaged, IPixel<TPixel2>
|
|
{
|
|
// Ensure the images are the same size
|
|
var (small, large) = target.Width < multiplier.Width && target.Height < multiplier.Height
|
|
? ((Image)target, (Image)multiplier)
|
|
: (multiplier, target);
|
|
small.Mutate(context => context.Resize(large.Width, large.Height));
|
|
|
|
var operation = new MultiplyOperation<TPixel1, TPixel2>(target, multiplier);
|
|
ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, target.Bounds(), in operation);
|
|
}
|
|
}
|
|
|
|
private readonly struct MultiplyOperation<TPixel1, TPixel2>(Image<TPixel1> target, Image<TPixel2> multiplier) : IRowOperation
|
|
where TPixel1 : unmanaged, IPixel<TPixel1>
|
|
where TPixel2 : unmanaged, IPixel<TPixel2>
|
|
{
|
|
|
|
public void Invoke(int y)
|
|
{
|
|
var targetSpan = target.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y);
|
|
var multiplierSpan = multiplier.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y);
|
|
|
|
for (int x = 0; x < targetSpan.Length; x++)
|
|
{
|
|
targetSpan[x].FromVector4(targetSpan[x].ToVector4() * multiplierSpan[x].ToVector4());
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: These are hardcoded colours - I'm not keen on supporting highly customiseable exports, but there's possibly some more sensible values to use here.
|
|
private static Vector4 _defaultHairColor = new Vector4(130, 64, 13, 255) / new Vector4(255);
|
|
private static Vector4 _defaultHighlightColor = new Vector4(77, 126, 240, 255) / new Vector4(255);
|
|
|
|
private static MaterialBuilder BuildHair(Material material, string name)
|
|
{
|
|
// Trust me bro.
|
|
const uint categoryHairType = 0x24826489;
|
|
const uint valueFace = 0x6E5B8F10;
|
|
|
|
var isFace = material.Mtrl.ShaderPackage.ShaderKeys
|
|
.Any(key => key.Category == categoryHairType && key.Value == valueFace);
|
|
|
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
|
var mask = material.Textures[TextureUsage.SamplerMask];
|
|
|
|
mask.Mutate(context => context.Resize(normal.Width, normal.Height));
|
|
|
|
var baseColor = new Image<Rgba32>(normal.Width, normal.Height);
|
|
normal.ProcessPixelRows(mask, baseColor, (normalAccessor, maskAccessor, baseColorAccessor) =>
|
|
{
|
|
for (int y = 0; y < normalAccessor.Height; y++)
|
|
{
|
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
|
var maskSpan = maskAccessor.GetRowSpan(y);
|
|
var baseColorSpan = baseColorAccessor.GetRowSpan(y);
|
|
|
|
for (int x = 0; x < normalSpan.Length; x++)
|
|
{
|
|
var color = Vector4.Lerp(_defaultHairColor, _defaultHighlightColor, maskSpan[x].A / 255f);
|
|
baseColorSpan[x].FromVector4(color * new Vector4(maskSpan[x].R / 255f));
|
|
baseColorSpan[x].A = normalSpan[x].A;
|
|
|
|
normalSpan[x].A = byte.MaxValue;
|
|
}
|
|
}
|
|
});
|
|
|
|
return BuildSharedBase(material, name)
|
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
|
.WithNormal(BuildImage(normal, name, "normal"))
|
|
.WithAlpha(isFace? AlphaMode.BLEND : AlphaMode.MASK, 0.5f);
|
|
}
|
|
|
|
private static Vector4 _defaultEyeColor = new Vector4(21, 176, 172, 255) / new Vector4(255);
|
|
|
|
// NOTE: This is largely the same as the hair material, but is also missing a few features that would cause it to diverge. Keeping seperate for now.
|
|
private static MaterialBuilder BuildIris(Material material, string name)
|
|
{
|
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
|
var mask = material.Textures[TextureUsage.SamplerMask];
|
|
|
|
mask.Mutate(context => context.Resize(normal.Width, normal.Height));
|
|
|
|
var baseColor = new Image<Rgba32>(normal.Width, normal.Height);
|
|
normal.ProcessPixelRows(mask, baseColor, (normalAccessor, maskAccessor, baseColorAccessor) =>
|
|
{
|
|
for (int y = 0; y < normalAccessor.Height; y++)
|
|
{
|
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
|
var maskSpan = maskAccessor.GetRowSpan(y);
|
|
var baseColorSpan = baseColorAccessor.GetRowSpan(y);
|
|
|
|
for (int x = 0; x < normalSpan.Length; x++)
|
|
{
|
|
baseColorSpan[x].FromVector4(_defaultEyeColor * new Vector4(maskSpan[x].R / 255f));
|
|
baseColorSpan[x].A = normalSpan[x].A;
|
|
|
|
normalSpan[x].A = byte.MaxValue;
|
|
}
|
|
}
|
|
});
|
|
|
|
return BuildSharedBase(material, name)
|
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
|
.WithNormal(BuildImage(normal, name, "normal"));
|
|
}
|
|
|
|
private static MaterialBuilder BuildSkin(Material material, string name)
|
|
{
|
|
// Trust me bro.
|
|
const uint categorySkinType = 0x380CAED0;
|
|
const uint valueFace = 0xF5673524;
|
|
|
|
// Face is the default for the skin shader, so a lack of skin type category is also correct.
|
|
var isFace = !material.Mtrl.ShaderPackage.ShaderKeys
|
|
.Any(key => key.Category == categorySkinType && key.Value != valueFace);
|
|
|
|
// TODO: There's more nuance to skin than this, but this should be enough for a baseline reference.
|
|
// TODO: Specular?
|
|
var diffuse = material.Textures[TextureUsage.SamplerDiffuse];
|
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
|
|
|
// Create a copy of the normal that's the same size as the diffuse for purposes of copying the opacity across.
|
|
var resizedNormal = normal.Clone(context => context.Resize(diffuse.Width, diffuse.Height));
|
|
diffuse.ProcessPixelRows(resizedNormal, (diffuseAccessor, normalAccessor) =>
|
|
{
|
|
for (int y = 0; y < diffuseAccessor.Height; y++)
|
|
{
|
|
var diffuseSpan = diffuseAccessor.GetRowSpan(y);
|
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
|
|
|
for (int x = 0; x < diffuseSpan.Length; x++)
|
|
{
|
|
diffuseSpan[x].A = normalSpan[x].B;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Clear the blue channel out of the normal now that we're done with it.
|
|
normal.ProcessPixelRows(normalAccessor => {
|
|
for (int y = 0; y < normalAccessor.Height; y++)
|
|
{
|
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
|
|
|
for (int x = 0; x < normalSpan.Length; x++)
|
|
{
|
|
normalSpan[x].B = byte.MaxValue;
|
|
}
|
|
}
|
|
});
|
|
|
|
return BuildSharedBase(material, name)
|
|
.WithBaseColor(BuildImage(diffuse, name, "basecolor"))
|
|
.WithNormal(BuildImage(normal, name, "normal"))
|
|
.WithAlpha(isFace? AlphaMode.MASK : AlphaMode.OPAQUE, 0.5f);
|
|
}
|
|
|
|
private static MaterialBuilder BuildFallback(Material material, string name)
|
|
{
|
|
Penumbra.Log.Warning($"Unhandled shader package: {material.Mtrl.ShaderPackage.Name}");
|
|
|
|
var materialBuilder = BuildSharedBase(material, name)
|
|
.WithMetallicRoughnessShader()
|
|
.WithBaseColor(Vector4.One);
|
|
|
|
if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse))
|
|
materialBuilder.WithBaseColor(BuildImage(diffuse, name, "basecolor"));
|
|
|
|
if (material.Textures.TryGetValue(TextureUsage.SamplerNormal, out var normal))
|
|
materialBuilder.WithNormal(BuildImage(normal, name, "normal"));
|
|
|
|
return materialBuilder;
|
|
}
|
|
|
|
private static MaterialBuilder BuildSharedBase(Material material, string name)
|
|
{
|
|
// TODO: Move this and potentially the other known stuff into MtrlFile?
|
|
const uint backfaceMask = 0x1;
|
|
var showBackfaces = (material.Mtrl.ShaderPackage.Flags & backfaceMask) == 0;
|
|
|
|
return new MaterialBuilder(name)
|
|
.WithDoubleSide(showBackfaces);
|
|
}
|
|
|
|
private static ImageBuilder BuildImage(Image image, string materialName, string suffix)
|
|
{
|
|
var name = materialName.Replace("/", "").Replace(".mtrl", "") + $"_{suffix}";
|
|
|
|
byte[] textureBytes;
|
|
using (var memoryStream = new MemoryStream())
|
|
{
|
|
image.Save(memoryStream, PngFormat.Instance);
|
|
textureBytes = memoryStream.ToArray();
|
|
}
|
|
|
|
var imageBuilder = ImageBuilder.From(textureBytes, name);
|
|
imageBuilder.AlternateWriteFileName = $"{name}.*";
|
|
return imageBuilder;
|
|
}
|
|
}
|