Торговцы-призраки: настоящая прозрачность тела, матовость, консоль
Прозрачность у торговцев управляла только бородой. Причина была напечатана
зондом ещё при первом заходе: у шейдера тела (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user