Skip to content

Commit

Permalink
Part-level material switching (#8)
Browse files Browse the repository at this point in the history
This is a (vastly simplified) replacement for TU's shader replacement
functionality.

A new top-level **`SHABBY_MATERIAL_DEF`** node is introduced. It
specifies a replacement shader and any number of shader properties to be
modified and contains the following items:
- **`name`** (required): a unique identifier for this material.
- `displayName` (optional): a human-facing name. Not used in Shabby but
may be accessed by other mods depending on this mod. Defaults to `name`
if not specified.
- `updateExisting` (optional): whether to apply changes to the existing
material, or to create a new material from scratch. Defaults to `true`.
- **`shader`** (optional in update-existing mode, otherwise required):
name of the shader to apply. May be a stock shader or one loaded by
Shabby.
- `preserveRenderQueue` (optional): whether the existing render queue of
the material should be preserved when its shader is replaced. Defaults
to `false`, which will reset the render queue to the shader's default.
- One or none of each the following nodes, to specify the corresponding
type of shader property to be applied. They contain any number of keys
of the format `_Property = value`:
  - `KEYWORD {}`: the value is a boolean (`true` or `false`).
  - `FLOAT {}`
- `COLOR {}`: the value is either a float color (`r, g, b` or `r, g, b,
a` normalized to `[0, 1]`), an HTML hex color (`#rgb`, `#rrggbb`,
`#rgba`, or `#rrggbbaa`), or a named Unity color.
- `VECTOR {}`: the value is a Vector4. All four components must be
specified.
- `TEXTURE {}`: the value is a GameData-relative path to a texture file,
sans extension.

There is also a new configuration under the existing `SHABBY` top-level
node, **`ICON_SHADER`**, with the string-valued keys `shader` and
`iconShader`. It specifies that the shader with the given name is to be
substituted when used in an icon prefab. **Without this configuration,
all custom shaders will be replaced by stock code with
`KSP/ScreenSpaceMask` in icon prefabs and thus editor part icons.**

Material replacements are applied in `PART`s using the
**`SHABBY_MATERIAL_REPLACE`** node. It contains the following items:
- **`materialDef`** (required): the unique identifier of the material
definition to apply.
- Optionally, exactly one of the following. If neither are specified,
the replacement is applied to the entire part.
1. At least one `targetMaterial` key with a string value, specifying
that the existing material with that name is to be replaced. This is the
recommended workflow.
2. At least one `targetTransform` key with a string value, specifying
that all meshes under that transform (recursively) are to have their
materials replaced.
- Optionally, any number of `ignoreMesh` keys with string values,
specifying that the mesh with the given name is to be ignored even if it
matches one of the target conditions. This applies to the mesh itself
only, not any of its children.

Multiple material replacement nodes may be defined in the part, but they
should apply to distinct meshes. The behavior in case of overlap is
unspecified.

All of the above configurations may be modified by ModuleManager.

The material replacement is performed once per part, during prefab
compilation. The result is indistinguishable from the model `mu`
natively containing the replaced material. In particular, this is
compatible with existing texture switching mods such as B9PartSwitch.

An example configuration:

```text
SHABBY_MATERIAL_DEF
{
    name = ExampleMaterial

    shader = TU/Metallic  // This is just an example; to use a TU shader one must load it as a `.shab` bundle first.

    Texture
    {
        _MainTex = MyMod/Assets/PBRdiff
        _MetallicGlossMap = MyMod/Assets/PBRmet
        _AOMap = MyMod/Assets/PBRmet
    }

    Float
    {
        _Metal = 0.5
        _Smoothness = 0.96
    }
}

@part[MyPart]:FOR[MyMod]
{
    SHABBY_MATERIAL_REPLACE
    {
        materialDef = ExampleMaterial
        targetTransform = transformA
        targetTransform = transformB
        excludeMesh = Flag
    }
}
```

Fixes #7.

---------

Co-authored-by: JonnyOThan <[email protected]>
  • Loading branch information
al2me6 and JonnyOThan authored Aug 9, 2024
1 parent e61ec50 commit 1aaa293
Show file tree
Hide file tree
Showing 6 changed files with 452 additions and 6 deletions.
91 changes: 91 additions & 0 deletions Source/IconMaterialPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
This file is part of Shabby.
Shabby is free software: you can redistribute it and/or
modify it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Shabby is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Shabby. If not, see
<http://www.gnu.org/licenses/>.
*/

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using HarmonyLib;
using UnityEngine;

namespace Shabby
{

[HarmonyPatch(typeof(PartLoader), "SetPartIconMaterials")]
class SetPartIconMaterialsPatch
{
static MethodInfo mInfo_ShaderFind = AccessTools.Method(typeof(Shader), nameof(Shader.Find));
static MethodInfo mInfo_FindOverrideIconShader = AccessTools.Method(typeof(SetPartIconMaterialsPatch), nameof(FindOverrideIconShader));

static Shader FindOverrideIconShader(Material material)
{
if (Shabby.iconShaders.TryGetValue(material.shader.name, out var shader)) {
Debug.Log($"[Shabby] custom icon shader {material.shader.name} -> {shader.name}");
return shader;
}
return Shabby.FindShader("KSP/ScreenSpaceMask");
}

/// <summary>
/// The stock method iterates through every material in the icon prefab and replaces some
/// stock shaders with 'ScreenSpaceMask'-prefixed ones. All shaders not explicitly checked,
/// including custom shaders, are replaced with 'KSP/ScreenSpaceMask'.
/// This transpiler inserts logic to check for additional replacements.
/// </summary>
static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
{
var code = instructions.ToList();
object locMaterial = null;

for (var i = 0; i < code.Count; ++i) {
// Material material = sharedMaterials[j];
// IL_002C ldloc.3
// IL_002D ldloc.s 4
// IL_002F ldelem.ref
// IL_0030 stloc.s 6
if (locMaterial == null
&& code[i].opcode == OpCodes.Ldloc_3
&& code[i+1].opcode == OpCodes.Ldloc_S
&& code[i+2].opcode == OpCodes.Ldelem_Ref
&& code[i+3].opcode == OpCodes.Stloc_S) {
// Extract the stack index of the material local.
locMaterial = code[i+3].operand;
}

// material2 = new Material(Shader.Find("KSP/ScreenSpaceMask"));
// IL_0191 ldstr "KSP/ScreenSpaceMask"
// IL_0196 call class UnityEngine.Shader UnityEngine.Shader::Find(string)
// IL_019D newobj instance void UnityEngine.Material::.ctor(class UnityEngine.Shader)
// IL_01A2 stloc.s 7
if (code[i].Is(OpCodes.Ldstr, "KSP/ScreenSpaceMask") && code[i+1].Calls(mInfo_ShaderFind)) {
// Replace the call to Shader.Find with FindOverrideIconShader(material).
if (locMaterial == null) break;
code[i].opcode = OpCodes.Ldloc_S;
code[i].operand = locMaterial;
code[i+1].operand = mInfo_FindOverrideIconShader;
Debug.Log("[Shabby] patched part icon shader replacement");
return code;
}
}

Debug.LogError("[Shabby] failed to patch part icon shader replacement");
return code;
}
}

}
4 changes: 4 additions & 0 deletions Source/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Shabby_FILES := \
assembly/VersionReport.cs \
Shabby.cs \
ShabLoader.cs \
IconMaterialPatch.cs \
MaterialDef.cs \
ModelFilter.cs \
MaterialReplacement.cs \
$e

RESGEN2 := resgen2
Expand Down
177 changes: 177 additions & 0 deletions Source/MaterialDef.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
This file is part of Shabby.
Shabby is free software: you can redistribute it and/or
modify it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Shabby is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Shabby. If not, see
<http://www.gnu.org/licenses/>.
*/

using System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;

namespace Shabby
{

public static class MaterialDefLibrary
{
public static readonly Dictionary<string, MaterialDef> items = new Dictionary<string, MaterialDef>();

public static void Load()
{
foreach (var node in GameDatabase.Instance.GetConfigNodes("SHABBY_MATERIAL_DEF")) {
var def = new MaterialDef(node);
if (string.IsNullOrEmpty(def.name) || !def.isValid) {
Debug.LogError($"[Shabby][MaterialDef {def.name}] removing invalid definition");
} else {
items[def.name] = def;
}
}
}
}

public class MaterialDef
{
[Persistent] public string name;
[Persistent(name = nameof(displayName))] private string _displayName = null;
public string displayName => _displayName ?? name;

[Persistent] public bool updateExisting = true;

[Persistent(name = "shader")] public string shaderName = null;
public readonly Shader shader = null;

[Persistent] public bool preserveRenderQueue = false;

public readonly Dictionary<string, bool> keywords;
public readonly Dictionary<string, float> floats;
public readonly Dictionary<string, Color> colors;
public readonly Dictionary<string, Vector4> vectors;
public readonly Dictionary<string, Texture> textures;

public readonly bool isValid = true;

public MaterialDef(ConfigNode node)
{
ConfigNode.LoadObjectFromConfig(this, node);

if (shaderName != null) {
shader = Shabby.FindShader(shaderName);
if (shader == null) {
Debug.LogError($"[Shabby][MaterialDef {name}] failed to find shader {shaderName}");
isValid = false;
}
}

if (!updateExisting && shader == null) {
Debug.LogError($"[Shabby][MaterialDef {name}] from-scratch material must define a valid shader");
isValid = false;
}

keywords = LoadDictionary<bool>(node, "KEYWORD");
floats = LoadDictionary<float>(node, "FLOAT");
colors = LoadDictionary<Color>(
node, "COLOR",
value => ParseColor(value, out var color) ? (object)color : null);
vectors = LoadDictionary<Vector4>(node, "VECTOR");
textures = LoadDictionary<Texture>(
node, "TEXTURE",
value => GameDatabase.Instance.GetTexture(value, asNormalMap: false));
}

static readonly Func<Type, string, object> ReadValue =
AccessTools.MethodDelegate<Func<Type, string, object>>(
AccessTools.DeclaredMethod(typeof(ConfigNode), "ReadValue"));

Dictionary<string, T> LoadDictionary<T>(ConfigNode defNode, string propKind, Func<string, object> parser = null)
{
var items = new Dictionary<string, T>();

var propNode = defNode.GetNode(propKind);
if (propNode == null) return items;

foreach (ConfigNode.Value item in propNode.values) {
object value = parser != null ? parser(item.value) : ReadValue(typeof(T), item.value);
if (value is T parsed) {
items[item.name] = parsed;
} else {
Debug.LogError($"[Shabby][MaterialDef {name}] failed to load {propKind} property {item.name} = {item.value}");
}
}

Debug.Log($"[Shabby][MaterialDef {name}] loaded {items.Count} {propKind} properties");
return items;
}

public static bool ParseColor(string value, out Color color)
{
if (ColorUtility.TryParseHtmlString(value, out color)) return true;
if (ParseExtensions.TryParseColor(value, out color)) return true;
return false;
}

static bool CheckProperty(Material mat, string propName)
{
var exists = mat.HasProperty(propName);
if (!exists) Debug.LogWarning($"[Shabby] shader {mat.shader.name} does not have property {propName}");
return exists;
}

/// <summary>
/// Create a new material based on this definition. The material name is copied from the
/// passed reference material. In update-existing mode, all properties are also copied from
/// the reference material.
/// </summary>
public Material Instantiate(Material referenceMaterial)
{
if (!isValid) return new Material(referenceMaterial);

Material material;
if (updateExisting) {
material = new Material(referenceMaterial);
if (shader != null) material.shader = shader;
} else {
material = new Material(shader) { name = referenceMaterial.name };
}

// Replacing the shader resets the render queue to the shader's default.
if (preserveRenderQueue) material.renderQueue = referenceMaterial.renderQueue;

foreach (var kvp in keywords) {
if (!CheckProperty(material, kvp.Key)) continue;
if (kvp.Value) material.EnableKeyword(kvp.Key);
else material.DisableKeyword(kvp.Key);
}

foreach (var kvp in floats) {
if (CheckProperty(material, kvp.Key)) material.SetFloat(kvp.Key, kvp.Value);
}

foreach (var kvp in colors) {
if (CheckProperty(material, kvp.Key)) material.SetColor(kvp.Key, kvp.Value);
}

foreach (var kvp in vectors) {
if (CheckProperty(material, kvp.Key)) material.SetVector(kvp.Key, kvp.Value);
}

foreach (var kvp in textures) {
if (CheckProperty(material, kvp.Key)) material.SetTexture(kvp.Key, kvp.Value);
}

return material;
}
}

}
98 changes: 98 additions & 0 deletions Source/MaterialReplacement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
This file is part of Shabby.
Shabby is free software: you can redistribute it and/or
modify it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Shabby is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Shabby. If not, see
<http://www.gnu.org/licenses/>.
*/

using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using UnityEngine;

namespace Shabby
{

public class MaterialReplacement : ModelFilter
{
public readonly MaterialDef materialDef = null;
readonly Dictionary<Material, Material> replacedMaterials = new Dictionary<Material, Material>();

public MaterialReplacement(ConfigNode node) : base(node)
{
var defName = node.GetValue("materialDef");
if (string.IsNullOrEmpty(defName)) {
Debug.LogError("[Shabby] material replacement must reference a material definition");
return;
}
if (!MaterialDefLibrary.items.TryGetValue(defName, out materialDef)) {
Debug.LogError($"[Shabby] failed to find valid material definition {defName}");
}
}

public void ApplyToSharedMaterialIfNotIgnored(Renderer renderer)
{
if (MatchIgnored(renderer)) return;
var sharedMat = renderer.sharedMaterial;
if (!replacedMaterials.TryGetValue(sharedMat, out var replacementMat)) {
replacementMat = materialDef.Instantiate(sharedMat);
replacedMaterials[sharedMat] = replacementMat;
}
renderer.sharedMaterial = replacementMat;
}
}

[HarmonyPatch(typeof(PartLoader), "CompileModel")]
class MaterialReplacementPatch
{
static void Postfix(ref GameObject __result, ConfigNode partCfg)
{
const string replacementNodeName = "SHABBY_MATERIAL_REPLACE";
if (!partCfg.HasNode(replacementNodeName)) return;

var replacements = new List<MaterialReplacement>();
foreach (ConfigNode node in partCfg.nodes) {
if (node.name != replacementNodeName) continue;
var replacement = new MaterialReplacement(node);
if (replacement.materialDef != null) replacements.Add(replacement);
}

// Apply blanket replacements or material name replacements.
foreach (var renderer in __result.GetComponentsInChildren<Renderer>()) {
foreach (var replacement in replacements) {
if (!replacement.blanketApply && !replacement.MatchMaterial(renderer)) continue;
replacement.ApplyToSharedMaterialIfNotIgnored(renderer);
break;
}
}

// Apply transform replacements.
if (replacements.Any(rep => rep.targetTransforms.Count > 0)) {
foreach (var transform in __result.GetComponentsInChildren<Transform>()) {
foreach (var replacement in replacements) {
if (!replacement.MatchTransform(transform)) continue;
foreach (var renderer in transform.GetComponentsInChildren<Renderer>()) {
replacement.ApplyToSharedMaterialIfNotIgnored(renderer);
}
break;
}
}
}

var replacementNames = string.Join(", ", replacements.Select(rep => rep.materialDef.name));
Debug.Log($"[Shabby] applied material replacements {replacementNames}");
}
}

}
Loading

0 comments on commit 1aaa293

Please sign in to comment.