Торговцы-призраки: настоящая прозрачность тела, матовость, консоль
Прозрачность у торговцев управляла только бородой. Причина была напечатана
зондом ещё при первом заходе: у шейдера тела (Game/Character) нет НИ цвета с
альфой, НИ режима смешивания - оба рычага ApplyTransparency существуют только
у шейдера волос (Game/Autodesk). Никакое значение альфы тело бы не тронуло.
Сначала добавлен третий рычаг - _Fade, собственный рычаг игры:
EntityModel.SetFade пишет ровно это свойство и отбирает материалы проверкой
HasProperty("_Fade") && shader.name.Contains("Game/Character"), а стоящий рядом
SetVisible(renderFade > 0.01f) закрывает направление: 1 = сплошной, 0 = исчез.
В игре выяснилось, что шейдер реализует его screen-door дизерингом - "тело
гаснет, но идёт мелкой сеточкой". Рычаг рабочий, но пиксели не смешиваются, а
выбрасываются, и никакое число этого не исправит. Оставлен как режим dither.
РАБОЧЕЕ РЕШЕНИЕ - перешивка тела на шейдер волос, у которого есть прозрачный
проход. Доказательство лежало на той же модели в том же кадре: борода всё это
время гасла плавно. Шейдер берётся С МОДЕЛИ - первый материал, умеющий
смешивать (есть цвет с альфой И режим смешивания); Shader.Find оставлен
запасным путём, потому что мод уже дважды получал null/негатив от семейства
Unlit. Решение принимается по способностям материала, имя шейдера нигде не
зашито. Переносятся только альбедо и нормаль: _RMOE - упакованная
roughness/metallic/occlusion/emission, а _MetallicGlossMap ждёт металл в R и
гладкость в A, каналы не совпадают, и связывание "на авось" даёт мокрый пластик
или негатив. Цена названа прямо: тело теряет собственную световую модель
шейдера персонажа и получает стандартную.
МАТОВОСТЬ по просьбе пользователя - три рычага, ломаются по-разному:
_Glossiness в 0 (чистая математика шейдера, работает всегда - несущая
половина); _SpecularHighlights и _GlossyReflections в 0 ВМЕСТЕ с ключевыми
словами _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, потому что это
[ToggleOff]-пара и запись одного флоата не делает ничего; карты глянца
очищаются, если непусты, - карта побеждает флоат. Оба keyword'а -
shader_feature, Unity вырезает такие на сборке, если их не выставляет ни один
материал игры, поэтому шершавость сделана основным рычагом, а не запасным.
Применяется ко всем призрачным материалам: волосы нарисованы тем же шейдером и
сохраняли свой блеск, а матовое тело под глянцевой бородой читалось бы хуже.
КОНСОЛЬНАЯ КОМАНДА necroghost (алиас necrotrader): проценты прозрачности,
blend/dither, reset. Балансировать рендер можно только глядя на него, а шаг без
команды стоил пересборки, перезапуска и ~4 минут пешком до торговца. Проценты,
а не альфа: это единица, в которой просьба формулировалась, и они идут в разные
стороны. Регистрации нет и не требуется - SdtdConsole.RegisterCommands ищет
через ReflectionHelpers.FindTypesImplementingBase, который обходит
ModManager.GetLoadedAssemblies(), а LoadMods() стоит на три строки раньше
RegisterCommands(). IsExecuteOnClient = true: команда меняет пиксели.
Две мины, обе реальные. Повторное применение не должно умножать: материалы
кэшируются, и sharedMaterials после первого прохода возвращает наши же клоны,
так что наивный повторный свип дал бы 0.9, потом 0.8 = 0.72; запоминается
базовое значение, живое всегда base * GhostAlpha. Запятая: StringParsers не
зависит от локали, но читает "," как разделитель ТЫСЯЧ, и "necroghost 12,5"
молча стало бы 125 - запятая заменяется на точку до парсинга.
DefaultGhostAlpha 0.9 -> 0.3: 70% прозрачности, найденные в игре. Путь был
1% -> 10% -> 70%, и последний скачок не смена вкуса - на 10% тело ещё
дизерилось, а дизеринг терпим лишь пока слаб. Поэтому же переписана заметка
"ниже ~0.7 силуэт разваливается": предел принадлежал технике, а не глазу.
---
Ghost traders: the body really blends, matte finish, console command
The transparency only ever reached the beard. The probe had already printed
why: the body's shader (Game/Character) has NEITHER a colour with an alpha NOR
a blend mode - both of ApplyTransparency's levers exist only on the hair's
shader (Game/Autodesk). No value of the alpha was ever going to touch it.
A third lever was added first - _Fade, the game's own: EntityModel.SetFade
writes exactly that property and guards it with HasProperty("_Fade") &&
shader.name.Contains("Game/Character"), and the SetVisible(renderFade > 0.01f)
sitting next to it settles the direction: 1 = solid, 0 = gone. In game the
shader turned out to implement it as screen-door dithering - "the body fades,
but goes to a fine grid". The lever works, but pixels are thrown away rather
than blended, and no number fixes that. It is kept as the dither mode.
WHAT ACTUALLY WORKS is re-shading the body onto the hair's shader, which does
have a transparent pass. The existence proof was on the same model in the same
frame: the beard had been fading smoothly all along. The shader is taken OFF
THE MODEL - the first material that can blend (a colour with an alpha AND a
blend mode); Shader.Find is kept only as a fallback, because this mod has twice
been handed null or a negative by the Unlit family. The decision is made on
what a material can do; no shader name is hard-coded. Only albedo and normal
are carried over: _RMOE is a packed roughness/metallic/occlusion/emission map
while _MetallicGlossMap wants metallic in R and smoothness in A - the channels
do not line up, and wiring them by hope is how a character ends up looking like
wet plastic or a negative. The trade is stated plainly: the body loses the
character shader's own lighting response and gets standard lighting instead.
MATTE, as requested - three levers that fail differently: _Glossiness to 0
(plain shader maths, always works - the load-bearing half); _SpecularHighlights
and _GlossyReflections to 0 TOGETHER WITH the _SPECULARHIGHLIGHTS_OFF /
_GLOSSYREFLECTIONS_OFF keywords, because they are a [ToggleOff] pair and
setting the float alone does nothing; and the gloss maps cleared if anything is
in them, since a map beats the float. Both keywords are shader_feature, which
Unity strips at build time if no material in the game sets them - which is why
roughness is the main lever and not the fallback. Applied to every ghost
material: the hair uses the same shader and kept its own shine, and a matte
body under a glossy beard would have read worse than either.
CONSOLE COMMAND necroghost (alias necrotrader): transparency in percent,
blend/dither, reset. A rendering balance can only be judged by looking at it,
and without the command each step cost a rebuild, a restart and a four-minute
walk to a trader. Percent rather than alpha: percent is the unit the request
was made in, and the two run in opposite directions. No registration is needed
- SdtdConsole.RegisterCommands goes through
ReflectionHelpers.FindTypesImplementingBase, which walks
ModManager.GetLoadedAssemblies(), and LoadMods() runs three lines before
RegisterCommands(). IsExecuteOnClient = true: the command changes pixels.
Two real traps. Re-applying must not compound: materials are cached and
sharedMaterials hands back our own clones after the first pass, so a naive
second sweep would give 0.9, then 0.8 = 0.72; the base value is remembered and
the live one is always base * GhostAlpha. The comma: StringParsers is
culture-independent but reads "," as a THOUSANDS separator, so "necroghost
12,5" would silently have become 125 - the comma is turned into a point first.
DefaultGhostAlpha 0.9 -> 0.3: the 70% transparency settled on in game. The road
was 1% -> 10% -> 70%, and the last jump was not a change of taste - at 10% the
body was still dithering, and a dither is bearable only while it is faint. For
the same reason the old "below ~0.7 the silhouette falls apart" note was
rewritten: that limit belonged to the technique, not to the eye.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ac575075a
commit
7768541f12
@@ -0,0 +1,174 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild
|
||||
/// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную
|
||||
/// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken
|
||||
/// model" - can only be judged by looking at him, and every step of that hunt otherwise costs
|
||||
/// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because
|
||||
/// traders are streamed in on approach. This collapses the loop to one line in the console.
|
||||
///
|
||||
/// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit
|
||||
/// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the
|
||||
/// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha
|
||||
/// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the
|
||||
/// unit and prints both back every time.
|
||||
///
|
||||
/// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right
|
||||
/// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships.
|
||||
/// A settings file would just be a second place for the answer to hide.
|
||||
///
|
||||
/// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes
|
||||
/// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that
|
||||
/// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract
|
||||
/// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either:
|
||||
/// GameManager calls ModManager.LoadMods() three lines before RegisterCommands().
|
||||
///
|
||||
/// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would
|
||||
/// otherwise run where there is nothing to look at; the flag makes the server bounce it back
|
||||
/// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the
|
||||
/// materials and the eyes are. In single player it changes nothing.
|
||||
/// </summary>
|
||||
[Preserve]
|
||||
public class ConsoleCmdNecroGhost : ConsoleCmdAbstract
|
||||
{
|
||||
public override bool IsExecuteOnClient => true;
|
||||
|
||||
public override bool AllowedInMainMenu => false;
|
||||
|
||||
public override string[] getCommands()
|
||||
{
|
||||
return new string[] { "necroghost", "necrotrader" };
|
||||
}
|
||||
|
||||
public override string getDescription()
|
||||
{
|
||||
return "Necromancer's Tome: how transparent the ghost traders are, in percent.";
|
||||
}
|
||||
|
||||
public override string getHelp()
|
||||
{
|
||||
return "necroghost - show the current value and mode\n" +
|
||||
"necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" +
|
||||
" 30 = clearly a ghost; past ~30 he stops reading as a body)\n" +
|
||||
"necroghost blend - fade the body by blending (re-shades it; smooth)\n" +
|
||||
"necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" +
|
||||
"necroghost reset - back to the built-in default value and mode\n" +
|
||||
"\n" +
|
||||
"Applies to traders already in the world, immediately - walk up to one first and\n" +
|
||||
"watch him while you type. Not saved: tell the mod author what you settled on.\n" +
|
||||
"\n" +
|
||||
"THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" +
|
||||
"game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" +
|
||||
"re-shades the body onto the hair's shader, which has a transparent pass, at the\n" +
|
||||
"cost of the character shader's own lighting. The hair fades the same way either\n" +
|
||||
"way, so it is the body you compare.\n" +
|
||||
"\n" +
|
||||
"If he comes apart instead of fading - teeth through the cheek, an arm through the\n" +
|
||||
"chest - that is not this number, that is depth writing, and no value here will fix it.";
|
||||
}
|
||||
|
||||
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||
{
|
||||
if (_params.Count == 0)
|
||||
{
|
||||
Report("Ghost traders");
|
||||
return;
|
||||
}
|
||||
|
||||
string argument = _params[0].Trim();
|
||||
if (argument.EqualsCaseInsensitive("reset"))
|
||||
{
|
||||
GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha;
|
||||
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset");
|
||||
return;
|
||||
}
|
||||
|
||||
if (argument.EqualsCaseInsensitive("blend"))
|
||||
{
|
||||
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (argument.EqualsCaseInsensitive("dither"))
|
||||
{
|
||||
SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryParsePercent(argument, out float percent))
|
||||
{
|
||||
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||
"necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " +
|
||||
"Try 'necroghost 10', or 'help necroghost'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (percent < 0f || percent > 100f)
|
||||
{
|
||||
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||
"necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible.");
|
||||
return;
|
||||
}
|
||||
|
||||
GhostTraderPatch.GhostAlpha = 1f - percent / 100f;
|
||||
Report("Set");
|
||||
|
||||
// Said only when asked for, and only once the value is actually past the point where
|
||||
// the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha.
|
||||
if (GhostTraderPatch.GhostAlpha < 0.7f)
|
||||
{
|
||||
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||
" (past ~30% the silhouette stops reading as a solid body at all, which looks like " +
|
||||
"a broken model for a different reason than depth writing does)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Switches how the body is faded and rebuilds the traders already standing, which
|
||||
/// is the expensive path - the materials have to be built again from the originals, since
|
||||
/// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here.
|
||||
/// Saying so out loud matters: this is the one thing in the command that is not free, and
|
||||
/// flipping modes back and forth while hunting a value is the obvious way to use it.</summary>
|
||||
public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix)
|
||||
{
|
||||
bool changed = GhostTraderPatch.BodyMode != _mode;
|
||||
GhostTraderPatch.BodyMode = _mode;
|
||||
int rebuilt = changed ? GhostTraderPatch.Reapply() : 0;
|
||||
Report(_prefix);
|
||||
if (changed && rebuilt > 0)
|
||||
{
|
||||
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current value plus what it actually reached, in both units, and which way the
|
||||
/// body is being faded. The count is the half that answers "did it do anything": 0
|
||||
/// materials means no trader has been converted yet - they stream in on approach - not
|
||||
/// that the number was refused.</summary>
|
||||
public static void Report(string _prefix)
|
||||
{
|
||||
float alpha = GhostTraderPatch.GhostAlpha;
|
||||
int applied = GhostTraderPatch.Retint();
|
||||
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||
_prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " +
|
||||
alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode +
|
||||
", applied to " + applied + " live material(s) across " +
|
||||
GhostTraderPatch.Ghosted.Count + " trader(s) converted this session.");
|
||||
}
|
||||
|
||||
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
|
||||
/// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator,
|
||||
/// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as
|
||||
/// 125 and the trader would vanish. The comma is turned into a point before it gets there.
|
||||
/// A trailing '%' is accepted because it is the obvious thing to type.</summary>
|
||||
public static bool TryParsePercent(string _argument, out float _percent)
|
||||
{
|
||||
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
|
||||
return StringParsers.TryParseFloat(text, out _percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
+522
-45
@@ -10,12 +10,13 @@ namespace NecromancerTome
|
||||
/// торговцев убираем совсем"). Fits the mod - the necromancer deals with the dead, and the
|
||||
/// only people still trading are not quite alive.
|
||||
///
|
||||
/// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER. The user first said "убираем
|
||||
/// совсем", confirmed the result ("торговец стал непрозрачным и полностью чёрно-белым, как и
|
||||
/// требовалось"), and then asked for "лёгкую прозрачность, буквально 1%" to push him a little
|
||||
/// further towards a ghost. Dropping it was still the release this effect needed, because it
|
||||
/// is what allowed the shader to stay put - see below; the 1% is now a separate, optional
|
||||
/// layer on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
|
||||
/// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER, THEN RAISED TO 10%. The user
|
||||
/// first said "убираем совсем", confirmed the result ("торговец стал непрозрачным и
|
||||
/// полностью чёрно-белым, как и требовалось"), then asked for "лёгкую прозрачность,
|
||||
/// буквально 1%", and on 2026-09-14 for 10% - "чтобы он выглядел как призрак, а не как
|
||||
/// сломанная модель". Dropping it was still the release this effect needed, because it
|
||||
/// is what allowed the shader to stay put - see below; the alpha is a separate, optional layer
|
||||
/// on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
|
||||
///
|
||||
/// The two failed attempts are worth keeping written down, because neither could have been
|
||||
/// predicted from the decompiler and each was settled by one log line:
|
||||
@@ -56,6 +57,10 @@ namespace NecromancerTome
|
||||
/// source texture: these are 4096x4096, and a readback per renderer per sweep would be
|
||||
/// indefensible.
|
||||
///
|
||||
/// THE ALPHA IS TURNED FROM THE CONSOLE, not from this file: `necroghost <percent>`, in
|
||||
/// GhostTraderCommand.cs. A rendering balance can only be judged by looking at it, and a
|
||||
/// rebuild-restart-walk-to-a-trader cycle per step is not a way to look at anything.
|
||||
///
|
||||
/// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning
|
||||
/// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built
|
||||
/// at runtime, so her renderers do not all exist when the entity is added to the world.
|
||||
@@ -68,15 +73,125 @@ namespace NecromancerTome
|
||||
/// <summary>Seconds between sweeps.</summary>
|
||||
public const float SweepInterval = 2f;
|
||||
|
||||
/// <summary>1 = solid. 0.99 is the "буквально 1%" the user asked for on 2026-09-13 after
|
||||
/// seeing the black-and-white traders: a hint of not-quite-there rather than a ghost.
|
||||
/// Deliberately close to opaque for a second reason too - see ApplyTransparency, which
|
||||
/// keeps depth writing on precisely because a nearly-solid character can afford to.</summary>
|
||||
public const float GhostAlpha = 0.99f;
|
||||
/// <summary>What a fresh game boots with. 1 = solid; 0.3 is what the hunt actually landed
|
||||
/// on - the user set 70% transparency in game on 2026-09-14 once the body was blending
|
||||
/// instead of dithering, and kept it. THE VALUE FOUND IN GAME BELONGS HERE: the console
|
||||
/// command turns GhostAlpha for the session only and deliberately persists nothing, so a
|
||||
/// number worth keeping has to be written into this line.
|
||||
///
|
||||
/// The road here was 1% -> 10% -> 70%, and the last jump was not a change of taste: at 10%
|
||||
/// the body was still being dithered, and a dither is only bearable while it is faint. Once
|
||||
/// it became real blending, far more of it turned out to look right.</summary>
|
||||
public const float DefaultGhostAlpha = 0.3f;
|
||||
|
||||
/// <summary>Colour properties that might carry an alpha, best first.</summary>
|
||||
/// <summary>The opacity actually in use. THIS IS THE ONE NUMBER TO TURN when hunting the
|
||||
/// balance between "ghost" and "broken model" - everything else in this file is about
|
||||
/// making the number mean what it says - and `necroghost <percent>` turns it live
|
||||
/// (GhostTraderCommand.cs), because every step of that hunt otherwise costs a rebuild, a
|
||||
/// restart and the four-minute walk to a trader.
|
||||
///
|
||||
/// Which way to turn it is decided by WHICH failure you are looking at, and the two look
|
||||
/// nothing alike:
|
||||
/// - reads as a solid person, no ghost at all -> lower it (0.85, 0.8).
|
||||
/// - the world shows through him but he still reads as one body -> this is the target.
|
||||
/// - you can see his teeth through his cheek, eyes through eyelids, an arm through the
|
||||
/// chest -> that is the "broken model", and it is NOT this number's fault. It means
|
||||
/// depth writing came off somewhere; see _ZWrite in ApplyTransparency. Dropping the
|
||||
/// alpha further only makes it worse.
|
||||
/// THE OLD "nothing below ~0.7" NOTE WAS WRONG, and it is worth saying why rather than
|
||||
/// quietly deleting: it was written while the body was still dithering, where a low value
|
||||
/// means a coarse pattern and the silhouette falls apart early. With the body blending,
|
||||
/// 0.3 reads as a ghost and holds together - the limit belonged to the technique, not to
|
||||
/// the eye. Depth writing is what keeps him one body, and it does not care how low the
|
||||
/// number goes.</summary>
|
||||
public static float GhostAlpha = DefaultGhostAlpha;
|
||||
|
||||
/// <summary>One opacity lever this patch installed on a trader, remembered so the alpha
|
||||
/// can be turned again WITHOUT rebuilding anything. Keeping the base value is the whole
|
||||
/// point: the live value is always base * GhostAlpha, never "multiply what is there now by
|
||||
/// the new number" - that would compound (0.9 then 0.8 would land on 0.72) and the second
|
||||
/// turn of the dial would lie about where it put you.
|
||||
///
|
||||
/// IsColour says which of the two kinds this is - the alpha channel of a colour, or a
|
||||
/// plain float - because a trader needs BOTH and they are not interchangeable: the body
|
||||
/// only has the float (_Fade) and the hair only has the colour (_Color). One material can
|
||||
/// therefore contribute one entry, and one trader contributes several of both kinds.</summary>
|
||||
public struct GhostMaterial
|
||||
{
|
||||
public Material Material;
|
||||
public string Property;
|
||||
public bool IsColour;
|
||||
public float BaseValue;
|
||||
}
|
||||
|
||||
/// <summary>A renderer this patch has taken over, with the materials it had before. The
|
||||
/// originals are the reason a mode can be switched at all: after the first pass
|
||||
/// renderer.sharedMaterials hands back OUR clones, so rebuilding from what is currently
|
||||
/// on the renderer would re-shade an already re-shaded material and there would be no way
|
||||
/// back to the body's real one. Rebuilding always starts here instead.</summary>
|
||||
public struct GhostRenderer
|
||||
{
|
||||
public Renderer Renderer;
|
||||
public Material[] Originals;
|
||||
}
|
||||
|
||||
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
|
||||
/// renderers as they are walked; dropped wholesale when the world unloads.</summary>
|
||||
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
|
||||
|
||||
/// <summary>Every material installed on a live trader that has somewhere to put an alpha.
|
||||
/// Retint walks this instead of redoing the work: re-running the sweep would re-clone the
|
||||
/// materials and, far worse, hand the 4096x4096 desaturation another readback per trader.
|
||||
/// Entries go stale when the game destroys the material with its trader - Retint prunes
|
||||
/// those, and WorldShuttingDown drops the lot.</summary>
|
||||
public static readonly List<GhostMaterial> TintedMaterials = new List<GhostMaterial>();
|
||||
|
||||
/// <summary>Colour properties that might carry an alpha, best first. Confirmed in game:
|
||||
/// the hair/beard shader "Game/Autodesk" has _Color; the BODY shader "Game/Character" has
|
||||
/// no colour property at all - see FadeNameHints.</summary>
|
||||
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
|
||||
|
||||
/// <summary>How the BODY is made see-through. The two are not two settings of one thing,
|
||||
/// they are two different renderers' worth of behaviour, and only a look in game can pick
|
||||
/// between them - which is why both stay reachable from the console.
|
||||
///
|
||||
/// Dither is the game's own _Fade, and on 2026-09-14 the user reported what it actually
|
||||
/// looks like: "тело гаснет, но идёт мелкой сеточкой". That is screen-door transparency -
|
||||
/// the shader is not blending anything, it is THROWING PIXELS AWAY in a fixed pattern.
|
||||
/// It is what an opaque-queue character shader can do without a transparent pass, and no
|
||||
/// value of GhostAlpha turns a dropped pixel into a translucent one.
|
||||
///
|
||||
/// Blend re-shades the body onto the shader the HAIR is already drawn with, which does
|
||||
/// have a transparent pass. The existence proof is on the same model in the same frame:
|
||||
/// the beard has been fading smoothly this whole time while the body was dithering.</summary>
|
||||
public enum BodyOpacityMode
|
||||
{
|
||||
Dither,
|
||||
Blend
|
||||
}
|
||||
|
||||
/// <summary>Blend by default: dither has been looked at and rejected. `necroghost blend`
|
||||
/// and `necroghost dither` switch it live - see GhostTraderCommand.cs.</summary>
|
||||
public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend;
|
||||
|
||||
/// <summary>The hair's shader, kept once it is found on a real trader. Taken off the model
|
||||
/// rather than through Shader.Find so it is the exact shader already proven to work in
|
||||
/// this scene - name lookups can miss a stripped or renamed shader and hand back null,
|
||||
/// and the mod has been down that road twice already with the Unlit family.</summary>
|
||||
public static Shader BlendShader;
|
||||
|
||||
/// <summary>Normal-map properties, best first. "Game/Character" calls it _Normal, the hair
|
||||
/// shader wants _BumpMap - the same disagreement the albedo has.</summary>
|
||||
public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" };
|
||||
|
||||
/// <summary>Float properties that fade the whole material out, best first. This is the
|
||||
/// body's only lever and the game's own: EntityModel.SetFade writes exactly "_Fade", and
|
||||
/// guards it with `material.HasProperty("_Fade") && shader.name.Contains("Game/Character")`
|
||||
/// - the same shader our traders' bodies are drawn with. EntityAlive.Update drives it from
|
||||
/// renderFade, and SetVisible(renderFade > 0.01f) right next to it settles the direction
|
||||
/// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary>
|
||||
public static readonly string[] FadeNameHints = { "_Fade" };
|
||||
|
||||
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
|
||||
public static readonly HashSet<int> Ghosted = new HashSet<int>();
|
||||
|
||||
@@ -109,6 +224,9 @@ namespace NecromancerTome
|
||||
{
|
||||
Ghosted.Clear();
|
||||
GreyTextures.Clear();
|
||||
TintedMaterials.Clear();
|
||||
Converted.Clear();
|
||||
BlendShader = null;
|
||||
timer = 0f;
|
||||
}
|
||||
|
||||
@@ -155,7 +273,12 @@ namespace NecromancerTome
|
||||
return false;
|
||||
}
|
||||
|
||||
// Before anything is touched, because the body's replacement shader is found on the
|
||||
// trader's OWN hair and the hair is not guaranteed to come first in this array.
|
||||
FindBlendShader(renderers);
|
||||
|
||||
int converted = 0;
|
||||
int leversBefore = TintedMaterials.Count;
|
||||
foreach (Renderer renderer in renderers)
|
||||
{
|
||||
if (renderer == null || renderer is ParticleSystemRenderer)
|
||||
@@ -169,26 +292,266 @@ namespace NecromancerTome
|
||||
continue;
|
||||
}
|
||||
|
||||
Material[] greys = new Material[sources.Length];
|
||||
bool anyChanged = false;
|
||||
for (int i = 0; i < sources.Length; i++)
|
||||
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
|
||||
if (Convert(renderer, sources))
|
||||
{
|
||||
greys[i] = MakeGreyMaterial(sources[i], ref anyChanged);
|
||||
}
|
||||
if (anyChanged)
|
||||
{
|
||||
renderer.materials = greys;
|
||||
converted++;
|
||||
}
|
||||
}
|
||||
|
||||
// The lever count is the half that answers "will the console command reach him":
|
||||
// desaturation and opacity come from different properties, and the body had the first
|
||||
// without the second until 2026-09-14. Fewer levers than converted renderers means
|
||||
// some part of this trader can only ever be black-and-white, never transparent.
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
|
||||
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + " renderer(s) desaturated");
|
||||
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length +
|
||||
" renderer(s) desaturated, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Clone of the source material - SAME shader, same everything - with only its
|
||||
/// albedo replaced by a black-and-white copy.</summary>
|
||||
/// <summary>Builds and installs this renderer's ghost materials from the ORIGINALS it was
|
||||
/// handed. True when anything was actually changed - a renderer nothing could be done to
|
||||
/// keeps the materials it has rather than being handed a half-built array.</summary>
|
||||
public static bool Convert(Renderer _renderer, Material[] _sources)
|
||||
{
|
||||
Material[] ghosts = new Material[_sources.Length];
|
||||
bool anyChanged = false;
|
||||
for (int i = 0; i < _sources.Length; i++)
|
||||
{
|
||||
ghosts[i] = MakeGreyMaterial(_sources[i], ref anyChanged);
|
||||
}
|
||||
if (anyChanged)
|
||||
{
|
||||
_renderer.materials = ghosts;
|
||||
}
|
||||
return anyChanged;
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds every trader already converted, from their original materials, under
|
||||
/// whatever BodyMode is set now. This is the expensive path and exists only for the mode
|
||||
/// switch; changing the alpha alone goes through Retint, which touches no materials at
|
||||
/// all. Even here the 4096x4096 desaturation is not redone - GreyTextures is keyed by the
|
||||
/// source texture, and the source is the same original every time.</summary>
|
||||
public static int Reapply()
|
||||
{
|
||||
TintedMaterials.Clear();
|
||||
int rebuilt = 0;
|
||||
for (int i = Converted.Count - 1; i >= 0; i--)
|
||||
{
|
||||
GhostRenderer entry = Converted[i];
|
||||
if (entry.Renderer == null)
|
||||
{
|
||||
Converted.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
if (Convert(entry.Renderer, entry.Originals))
|
||||
{
|
||||
rebuilt++;
|
||||
}
|
||||
}
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
/// <summary>Remembers the first shader on this model that can actually blend - it needs
|
||||
/// both a colour to put an alpha in and a blend mode to honour it. On a trader that is the
|
||||
/// hair's shader. Falls back to looking the name up only if the model somehow has no such
|
||||
/// material, and says so either way, because a null here silently disables the whole Blend
|
||||
/// mode and leaves the body dithering with no explanation.</summary>
|
||||
public static Shader FindBlendShader(Renderer[] _renderers)
|
||||
{
|
||||
if (BlendShader != null)
|
||||
{
|
||||
return BlendShader;
|
||||
}
|
||||
|
||||
foreach (Renderer renderer in _renderers)
|
||||
{
|
||||
if (renderer == null || renderer is ParticleSystemRenderer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Material[] materials = renderer.sharedMaterials;
|
||||
if (materials == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (Material material in materials)
|
||||
{
|
||||
if (material == null || material.shader == null || !CanBlendInPlace(material))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
BlendShader = material.shader;
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: blend shader taken off the model: '" +
|
||||
BlendShader.name + "'");
|
||||
return BlendShader;
|
||||
}
|
||||
}
|
||||
|
||||
BlendShader = Shader.Find("Game/Autodesk");
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: no blend-capable material on this model; " +
|
||||
"the shader was looked up by name instead and came back " +
|
||||
(BlendShader != null ? BlendShader.name : "NULL - the body stays dithered"));
|
||||
return BlendShader;
|
||||
}
|
||||
|
||||
/// <summary>Whether this material can be made see-through where it stands: somewhere to
|
||||
/// put an alpha, and a blend mode to make the alpha mean something. The body fails both
|
||||
/// halves and the hair passes both - which is the whole difference between them, and the
|
||||
/// reason this is a capability test rather than a check on the shader's name.</summary>
|
||||
public static bool CanBlendInPlace(Material _material)
|
||||
{
|
||||
if (!_material.HasProperty("_SrcBlend") || !_material.HasProperty("_DstBlend"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string hint in TintNameHints)
|
||||
{
|
||||
if (_material.HasProperty(hint))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The body, re-shaded onto the hair's shader so it can blend the way the hair does, with
|
||||
/// the black-and-white albedo it would have had anyway.
|
||||
///
|
||||
/// ONLY WHAT IS UNDERSTOOD IS CARRIED OVER - albedo and normal map. The body's third map,
|
||||
/// _RMOE, is a packed roughness/metallic/occlusion/emission texture, and the destination
|
||||
/// shader's _MetallicGlossMap expects metallic in R and smoothness in A: the channels do
|
||||
/// not line up, and wiring them by hope is exactly how a character comes out looking like
|
||||
/// wet plastic or a negative. It is left empty and the two floats are set by hand instead
|
||||
/// - no metal, barely any gloss, which is what a dead man in black and white should be.
|
||||
///
|
||||
/// This is a real trade and worth stating plainly: the body loses the character shader's
|
||||
/// own lighting response and gets standard lighting instead. In exchange it can actually
|
||||
/// be translucent rather than dithered. If it reads wrong in game, `necroghost dither` is
|
||||
/// one line away and nothing has to be rebuilt.
|
||||
/// </summary>
|
||||
public static Material MakeBlendMaterial(Material _source, Texture2D _albedo)
|
||||
{
|
||||
Material blend = new Material(BlendShader);
|
||||
blend.SetTexture("_MainTex", _albedo);
|
||||
|
||||
Texture normal = FindTexture(_source, NormalNameHints);
|
||||
if (normal != null && blend.HasProperty("_BumpMap"))
|
||||
{
|
||||
blend.SetTexture("_BumpMap", normal);
|
||||
}
|
||||
MakeMatte(blend);
|
||||
// Standard's own "Fade" setting. The shader branches on keywords rather than on this
|
||||
// float, and ApplyTransparency sets those - but a material inspected later with its
|
||||
// mode still reading "Opaque" is a trap for whoever looks next.
|
||||
if (blend.HasProperty("_Mode"))
|
||||
{
|
||||
blend.SetFloat("_Mode", 2f);
|
||||
}
|
||||
|
||||
ApplyTransparency(blend);
|
||||
return blend;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the shine off (user request 2026-09-14, once the body was blending properly:
|
||||
/// "слишком оно бликует, я бы вообще сделал матовым"). Applied to EVERY ghost material, not just the
|
||||
/// re-shaded body: the hair is drawn by the same shader and was keeping its own shine, and
|
||||
/// a matte body under a glossy beard would have read worse than either.
|
||||
///
|
||||
/// THREE THINGS, BECAUSE THEY FAIL DIFFERENTLY:
|
||||
///
|
||||
/// - _Glossiness (smoothness) to 0. This one is plain shader maths and always works -
|
||||
/// the surface becomes maximally rough, and a rough surface has no tight highlight.
|
||||
/// It is the load-bearing half of this method.
|
||||
/// - _SpecularHighlights / _GlossyReflections to 0 WITH their keywords. These are the
|
||||
/// Standard shader's [ToggleOff] pair: the float is only what the inspector shows, the
|
||||
/// shader branches on _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, so setting the
|
||||
/// float alone does nothing at all. They kill the direct highlight and the reflection
|
||||
/// probe contribution outright.
|
||||
/// - The gloss MAPS, cleared if anything is in them. A map wins over the float: leave
|
||||
/// one in place and _Glossiness is ignored, which is the silent way for all of this to
|
||||
/// appear to do nothing. They are empty on these traders today - this is for the next
|
||||
/// model that is not.
|
||||
///
|
||||
/// HONEST LIMIT: those two keywords are `shader_feature`, which Unity strips at build time
|
||||
/// if no material in the game ships with them set. If they were stripped, EnableKeyword is
|
||||
/// a no-op and only the roughness half lands. That is why the roughness half is not
|
||||
/// treated as a fallback but as the main lever - and why this is not attempted at all on
|
||||
/// the body's own "Game/Character" shader, which has none of these properties: its shine
|
||||
/// lives inside the packed _RMOE texture and would have to be repainted, not switched off.
|
||||
/// </summary>
|
||||
public static void MakeMatte(Material _material)
|
||||
{
|
||||
if (_material.HasProperty("_Glossiness"))
|
||||
{
|
||||
_material.SetFloat("_Glossiness", 0f);
|
||||
}
|
||||
if (_material.HasProperty("_GlossMapScale"))
|
||||
{
|
||||
_material.SetFloat("_GlossMapScale", 0f);
|
||||
}
|
||||
if (_material.HasProperty("_Metallic"))
|
||||
{
|
||||
_material.SetFloat("_Metallic", 0f);
|
||||
}
|
||||
if (_material.HasProperty("_SpecularHighlights"))
|
||||
{
|
||||
_material.SetFloat("_SpecularHighlights", 0f);
|
||||
_material.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
|
||||
}
|
||||
if (_material.HasProperty("_GlossyReflections"))
|
||||
{
|
||||
_material.SetFloat("_GlossyReflections", 0f);
|
||||
_material.EnableKeyword("_GLOSSYREFLECTIONS_OFF");
|
||||
}
|
||||
ClearTexture(_material, "_SpecGlossMap");
|
||||
ClearTexture(_material, "_MetallicGlossMap");
|
||||
}
|
||||
|
||||
/// <summary>Empties a texture slot if this material has one and it is not already empty.
|
||||
/// </summary>
|
||||
public static void ClearTexture(Material _material, string _property)
|
||||
{
|
||||
if (_material.HasProperty(_property) && _material.GetTexture(_property) != null)
|
||||
{
|
||||
_material.SetTexture(_property, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>First texture among these property names that this material actually has
|
||||
/// something in.</summary>
|
||||
public static Texture FindTexture(Material _source, string[] _hints)
|
||||
{
|
||||
foreach (string hint in _hints)
|
||||
{
|
||||
if (_source.HasProperty(hint))
|
||||
{
|
||||
Texture texture = _source.GetTexture(hint);
|
||||
if (texture != null)
|
||||
{
|
||||
return texture;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This material's ghost version. Two shapes, and which one it takes is decided by what
|
||||
/// the material can do rather than by what it is called:
|
||||
///
|
||||
/// - CAN be made see-through where it stands (the hair) -> a clone with the SAME shader
|
||||
/// and only its albedo swapped for a black-and-white copy. Lighting, normal maps,
|
||||
/// specular, skinning - all still the game's own.
|
||||
/// - CANNOT (the body: no colour, no blend mode) -> under BodyMode.Blend it is re-shaded
|
||||
/// onto the shader the hair uses, which can. Under BodyMode.Dither it takes the clone
|
||||
/// path anyway and gets the _Fade lever, which works but throws pixels away.
|
||||
///
|
||||
/// The albedo is desaturated first either way, because both shapes need it and the result
|
||||
/// is cached per source texture - the re-shaded body is not paying for a second readback.
|
||||
/// </summary>
|
||||
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
|
||||
{
|
||||
if (_source == null)
|
||||
@@ -198,48 +561,83 @@ namespace NecromancerTome
|
||||
|
||||
ProbeShaderOnce(_source);
|
||||
|
||||
string albedoProperty = FindAlbedoProperty(_source);
|
||||
Texture2D desaturated = albedoProperty != null
|
||||
? Desaturate(_source.GetTexture(albedoProperty))
|
||||
: null;
|
||||
|
||||
// Re-shading without an albedo to hand over would produce an untextured silhouette -
|
||||
// the exact failure this patch already shipped once, on 2026-09-13. If the texture
|
||||
// could not be read, the body keeps its own shader and stays dithered instead, and
|
||||
// the lever count in the log is what says so.
|
||||
if (BodyMode == BodyOpacityMode.Blend && BlendShader != null && desaturated != null &&
|
||||
!CanBlendInPlace(_source))
|
||||
{
|
||||
_changed = true;
|
||||
return MakeBlendMaterial(_source, desaturated);
|
||||
}
|
||||
|
||||
Material grey = new Material(_source);
|
||||
MakeMatte(grey);
|
||||
if (ApplyTransparency(grey))
|
||||
{
|
||||
_changed = true;
|
||||
}
|
||||
string albedoProperty = FindAlbedoProperty(_source);
|
||||
if (albedoProperty == null)
|
||||
if (desaturated == null)
|
||||
{
|
||||
// Nothing to desaturate on this material; hand back the clone unchanged rather
|
||||
// than dropping the renderer's material entirely.
|
||||
return grey;
|
||||
}
|
||||
|
||||
Texture2D desaturated = Desaturate(_source.GetTexture(albedoProperty));
|
||||
if (desaturated == null)
|
||||
{
|
||||
return grey;
|
||||
}
|
||||
|
||||
grey.SetTexture(albedoProperty, desaturated);
|
||||
_changed = true;
|
||||
return grey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the material blend instead of being drawn solid, then dials its alpha down by the
|
||||
/// requested sliver. Two levers, both conditional, because the traders' own shaders are
|
||||
/// game-specific and nothing about them can be assumed:
|
||||
/// Dials this material's opacity down to GhostAlpha. THREE levers, each conditional,
|
||||
/// because a trader is drawn by two different shaders that have almost nothing in common -
|
||||
/// and the first version of this method only had the levers the hair happens to own, which
|
||||
/// is why the user reported on 2026-09-14 that the console command "управляет только
|
||||
/// прозрачностью бороды". The probe had already printed the reason, one line each:
|
||||
///
|
||||
/// - A COLOUR with an alpha channel (_Color and friends). This is the only thing that
|
||||
/// actually sets the opacity.
|
||||
/// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096;
|
||||
/// tint property: <none>; blend-mode properties present: False
|
||||
/// shader 'Game/Autodesk' - albedo: _MainTex -> HD_Rekt_Hair 2048x2048;
|
||||
/// tint property: _Color (alpha 1); blend-mode properties present: True
|
||||
///
|
||||
/// The BODY has no colour property and no blend mode at all. Two of the three levers below
|
||||
/// simply do not exist on it, and no value of GhostAlpha was ever going to reach it.
|
||||
///
|
||||
/// - A COLOUR with an alpha channel (_Color and friends). The hair's lever; the body
|
||||
/// does not have one.
|
||||
/// - THE FADE FLOAT (_Fade). The body's lever, and the game's own: EntityModel.SetFade
|
||||
/// writes this exact property and guards it with a check for the "Game/Character"
|
||||
/// shader by name, so this is not a property we found and hoped about - it is the one
|
||||
/// the engine itself fades these very models with. Nothing else in the body's property
|
||||
/// list can carry an opacity: the rest is _Albedo/_Normal/_RMOE, an alpha-CUTOUT
|
||||
/// cluster (_AlphaCut, _AlphaCutoff, _SoftAlphaCutoff, _AlphaSoftness - a dissolve,
|
||||
/// which punches holes rather than making glass), _EmissiveColor, and flags.
|
||||
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
|
||||
/// handed, so without this the first lever does nothing visible - the same wall the
|
||||
/// handed, so without this the COLOUR lever does nothing visible - the same wall the
|
||||
/// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets.
|
||||
/// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes
|
||||
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword.
|
||||
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword. The _Fade
|
||||
/// lever needs none of it - the shader does its own fading internally, which is
|
||||
/// exactly why it exists.
|
||||
///
|
||||
/// _ZWrite IS LEFT ALONE ON PURPOSE. The usual recipe switches depth writing off, which is
|
||||
/// right for glass and wrong for a person: without it every part of the model shows through
|
||||
/// every other part and the trader turns into a soup of overlapping limbs. At 99% opacity
|
||||
/// there is nothing to see through anyway, so keeping depth writing costs nothing visible
|
||||
/// and avoids that entirely.
|
||||
/// _ZWrite IS LEFT ALONE ON PURPOSE, AND AT 10% THAT IS THE WHOLE BALANCE. The usual
|
||||
/// transparency recipe switches depth writing off, which is right for glass and wrong for
|
||||
/// a person: without it every surface of the model blends over every other one, and the
|
||||
/// trader becomes teeth through cheeks, eyeballs through eyelids, the far arm through the
|
||||
/// chest - exactly the "сломанная модель" the user does not want. With it on, the depth
|
||||
/// test keeps only the nearest surface and that one surface blends with the WORLD behind
|
||||
/// him. So he goes see-through without coming apart: a ghost, not a mess.
|
||||
///
|
||||
/// At 99% this cost nothing because there was nothing to see through; at 90% it is the
|
||||
/// reason the effect is usable at all. Note that it is not set here either way - these
|
||||
/// shaders write depth by default, and leaving the property untouched is what keeps it.
|
||||
///
|
||||
/// Whatever is missing is reported by the probe rather than silently skipped - if neither
|
||||
/// lever exists on these shaders, the traders stay solid black-and-white and the log says
|
||||
@@ -256,8 +654,43 @@ namespace NecromancerTome
|
||||
continue;
|
||||
}
|
||||
Color tint = _material.GetColor(hint);
|
||||
tint.a *= GhostAlpha;
|
||||
float baseAlpha = tint.a;
|
||||
tint.a = baseAlpha * GhostAlpha;
|
||||
_material.SetColor(hint, tint);
|
||||
TintedMaterials.Add(new GhostMaterial
|
||||
{
|
||||
Material = _material,
|
||||
Property = hint,
|
||||
IsColour = true,
|
||||
BaseValue = baseAlpha
|
||||
});
|
||||
touched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (string hint in FadeNameHints)
|
||||
{
|
||||
if (!_material.HasProperty(hint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A base of 0 would mean the material is already fully faded out, which no
|
||||
// standing trader is - it means the property is sitting at a default nobody set.
|
||||
// Multiplying by it would make him vanish outright and no value of GhostAlpha
|
||||
// could bring him back, so it is read as "solid" instead.
|
||||
float baseFade = _material.GetFloat(hint);
|
||||
if (baseFade <= 0f)
|
||||
{
|
||||
baseFade = 1f;
|
||||
}
|
||||
_material.SetFloat(hint, baseFade * GhostAlpha);
|
||||
TintedMaterials.Add(new GhostMaterial
|
||||
{
|
||||
Material = _material,
|
||||
Property = hint,
|
||||
IsColour = false,
|
||||
BaseValue = baseFade
|
||||
});
|
||||
touched = true;
|
||||
break;
|
||||
}
|
||||
@@ -274,6 +707,40 @@ namespace NecromancerTome
|
||||
return touched;
|
||||
}
|
||||
|
||||
/// <summary>Pushes the current GhostAlpha onto every trader already standing in the world,
|
||||
/// and returns how many materials took it. Traders that spawn later need nothing from this
|
||||
/// - ApplyTransparency reads the same field on the way past.
|
||||
///
|
||||
/// Unity's fake null is the reason for the rebuild-in-place rather than a simple loop: a
|
||||
/// destroyed Material compares equal to null but is still a live list entry, and touching
|
||||
/// it throws. Walking backwards and dropping those as we go keeps the list from growing
|
||||
/// across a session of traders streaming in and out.</summary>
|
||||
public static int Retint()
|
||||
{
|
||||
int applied = 0;
|
||||
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
|
||||
{
|
||||
GhostMaterial entry = TintedMaterials[i];
|
||||
if (entry.Material == null)
|
||||
{
|
||||
TintedMaterials.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
if (entry.IsColour)
|
||||
{
|
||||
Color tint = entry.Material.GetColor(entry.Property);
|
||||
tint.a = entry.BaseValue * GhostAlpha;
|
||||
entry.Material.SetColor(entry.Property, tint);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Material.SetFloat(entry.Property, entry.BaseValue * GhostAlpha);
|
||||
}
|
||||
applied++;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/// <summary>Name of the texture property holding this material's albedo, or null. Walks
|
||||
/// the shader's declared properties rather than assuming a name - the body and the hair of
|
||||
/// the same trader disagree about it.</summary>
|
||||
@@ -407,13 +874,23 @@ namespace NecromancerTome
|
||||
break;
|
||||
}
|
||||
}
|
||||
string fade = "<none>";
|
||||
foreach (string hint in FadeNameHints)
|
||||
{
|
||||
if (_source.HasProperty(hint))
|
||||
{
|
||||
fade = hint + " (" + _source.GetFloat(hint).ToString("0.###") + ")";
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool canBlend = _source.HasProperty("_SrcBlend") && _source.HasProperty("_DstBlend");
|
||||
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' properties: " +
|
||||
(sb.Length > 0 ? sb.ToString() : "<none>"));
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
|
||||
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
|
||||
"; tint property: " + tint + "; blend-mode properties present: " + canBlend);
|
||||
"; tint property: " + tint + "; fade property: " + fade +
|
||||
"; blend-mode properties present: " + canBlend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user