using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace NecromancerTome { /// /// Renders every trader in black and white (user request 2026-09-13: "сделать модельки всех /// торговцев полупрозрачными и чёрнобелыми", then after two in-game looks: "прозрачность у /// торговцев убираем совсем"). 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, 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: /// /// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has /// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader /// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе". /// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log. /// NGUI tints through VERTEX colours, not a material property, and a character mesh has /// none - so that whole family of shaders was always a dead end here. /// /// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is /// strictly better than anything above: the material is cloned with its own shader intact and /// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular, /// skinning - all still the game's own. The trader looks exactly like himself, in black and /// white. Nothing can go "negative", because nothing but the pixels changes. /// /// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first /// attempt reached for _MainTex and produced an untextured silhouette, because traders are /// drawn by TWO different shaders and only one of them uses that name. The probe below walks /// the shader's declared properties instead, and the log then said exactly what they are: /// /// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set, /// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096 /// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...; /// chosen albedo: HD_Rekt_Hair 2048x2048 /// /// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is /// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a /// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in /// entityclasses.xml) and may well introduce a third shader. /// /// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the /// point: game textures are compressed with isReadable=false, so GetPixels on the original /// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to /// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat /// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per /// 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 `, 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. /// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here - /// avoids guessing at the right moment inside someone else's character pipeline. A trader with /// no renderers yet is simply not marked done and is picked up on the next sweep. /// public static class GhostTraderPatch { /// Seconds between sweeps. public const float SweepInterval = 2f; /// 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. public const float DefaultGhostAlpha = 0.3f; /// 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. public static float GhostAlpha = DefaultGhostAlpha; /// 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. public struct GhostMaterial { public Material Material; public string Property; public bool IsColour; public float BaseValue; } /// 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. public struct GhostRenderer { public Renderer Renderer; public Material[] Originals; } /// Every renderer taken over, in the order it was found. Pruned of destroyed /// renderers as they are walked; dropped wholesale when the world unloads. public static readonly List Converted = new List(); /// 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. public static readonly List TintedMaterials = new List(); /// 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. public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" }; /// 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. public enum BodyOpacityMode { Dither, Blend } /// Blend by default: dither has been looked at and rejected. `necroghost blend` /// and `necroghost dither` switch it live - see GhostTraderCommand.cs. public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend; /// 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. public static Shader BlendShader; /// Normal-map properties, best first. "Game/Character" calls it _Normal, the hair /// shader wants _BumpMap - the same disagreement the albedo has. public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" }; /// 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. public static readonly string[] FadeNameHints = { "_Fade" }; /// Entity ids already converted. Cleared when the world unloads. public static readonly HashSet Ghosted = new HashSet(); /// Source shader names already described in the log, so the probe says each /// distinct thing once rather than once per trader per part. public static readonly HashSet ProbedShaders = new HashSet(); /// Desaturated copies, keyed by the texture they came from. public static readonly Dictionary GreyTextures = new Dictionary(); /// Property names that look like an albedo, best first. Confirmed in game: /// "Game/Character" uses _Albedo, "Game/Autodesk" uses _MainTex. Anything else falls /// through to "the first texture property that has something in it". public static readonly string[] AlbedoNameHints = { "_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap", "_Diffuse", "_ColorMap", "_MainTexture", "_Texture" }; public static float timer; /// Called from ModEntry.InitMod. public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown); } public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data) { Ghosted.Clear(); GreyTextures.Clear(); TintedMaterials.Clear(); Converted.Clear(); BlendShader = null; timer = 0f; } public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { timer += Time.deltaTime; if (timer < SweepInterval) { return; } timer = 0f; World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null || world.EntityAlives == null) { return; } for (int i = 0; i < world.EntityAlives.Count; i++) { EntityAlive entity = world.EntityAlives[i]; if (!(entity is EntityTrader trader) || trader.IsDead()) { continue; } if (Ghosted.Contains(trader.entityId)) { continue; } if (ApplyGreyscale(trader)) { Ghosted.Add(trader.entityId); } } } /// False when there is nothing to work on yet (model not built), so the caller /// leaves this trader unmarked and tries again on the next sweep. public static bool ApplyGreyscale(EntityTrader _trader) { Renderer[] renderers = _trader.GetComponentsInChildren(true); if (renderers == null || renderers.Length == 0) { 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) { continue; } Material[] sources = renderer.sharedMaterials; if (sources == null || sources.Length == 0) { continue; } Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources }); if (Convert(renderer, sources)) { 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, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed"); return true; } /// 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. 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; } /// 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. 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; } /// 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. 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; } /// 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. 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; } /// /// 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. /// 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; } /// /// 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. /// 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"); } /// Empties a texture slot if this material has one and it is not already empty. /// public static void ClearTexture(Material _material, string _property) { if (_material.HasProperty(_property) && _material.GetTexture(_property) != null) { _material.SetTexture(_property, null); } } /// First texture among these property names that this material actually has /// something in. 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; } /// /// 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. /// public static Material MakeGreyMaterial(Material _source, ref bool _changed) { if (_source == null) { return null; } 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; } if (desaturated == null) { // Nothing to desaturate on this material; hand back the clone unchanged rather // than dropping the renderer's material entirely. return grey; } grey.SetTexture(albedoProperty, desaturated); _changed = true; return grey; } /// /// 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: /// /// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096; /// tint property: ; 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 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. 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, 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 /// why. /// public static bool ApplyTransparency(Material _material) { bool touched = false; foreach (string hint in TintNameHints) { if (!_material.HasProperty(hint)) { continue; } Color tint = _material.GetColor(hint); 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; } if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend")) { _material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha); _material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha); _material.EnableKeyword("_ALPHABLEND_ON"); _material.renderQueue = (int)RenderQueue.Transparent; touched = true; } return touched; } /// 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. 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; } /// 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. public static string FindAlbedoProperty(Material _source) { Shader shader = _source.shader; if (shader == null) { return null; } foreach (string hint in AlbedoNameHints) { if (_source.HasProperty(hint) && _source.GetTexture(hint) != null) { return hint; } } int count = shader.GetPropertyCount(); for (int i = 0; i < count; i++) { if (shader.GetPropertyType(i) != ShaderPropertyType.Texture) { continue; } string name = shader.GetPropertyName(i); if (_source.GetTexture(name) != null) { return name; } } return null; } /// Black-and-white copy of a texture. See the class comment for why this goes /// through a RenderTexture instead of reading the source directly. public static Texture2D Desaturate(Texture _source) { if (_source == null) { return null; } if (GreyTextures.TryGetValue(_source, out Texture2D cached)) { return cached; } Texture2D grey = null; RenderTexture rt = null; RenderTexture previous = RenderTexture.active; try { rt = RenderTexture.GetTemporary(_source.width, _source.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); Graphics.Blit(_source, rt); RenderTexture.active = rt; grey = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false); grey.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0); Color32[] pixels = grey.GetPixels32(); for (int i = 0; i < pixels.Length; i++) { Color32 p = pixels[i]; byte luma = (byte)((p.r * 299 + p.g * 587 + p.b * 114) / 1000); p.r = luma; p.g = luma; p.b = luma; pixels[i] = p; } grey.SetPixels32(pixels); grey.Apply(false, false); } catch (System.Exception e) { Debug.LogError("[NecromancerTome] GhostTraderPatch: could not desaturate '" + _source.name + "': " + e.Message); grey = null; } finally { RenderTexture.active = previous; if (rt != null) { RenderTexture.ReleaseTemporary(rt); } } // Cached even on failure (as null) so an unreadable texture is not retried per trader. GreyTextures[_source] = grey; return grey; } /// Says, once per distinct source shader, which texture properties it has and /// which carry anything. This is what told us the body uses _Albedo and the hair _MainTex; /// it stays in because the next trader built by a different character system will announce /// itself the same way. public static void ProbeShaderOnce(Material _source) { Shader shader = _source.shader; string shaderName = shader != null ? shader.name : ""; if (!ProbedShaders.Add(shaderName) || shader == null) { return; } // EVERY property, not just the textures. The texture-only version answered the // "where is the albedo" question; this one has to answer "is there anything here that // can make it transparent at all", and that lives among the floats and colours. System.Text.StringBuilder sb = new System.Text.StringBuilder(); int count = shader.GetPropertyCount(); for (int i = 0; i < count; i++) { string name = shader.GetPropertyName(i); ShaderPropertyType type = shader.GetPropertyType(i); sb.Append(sb.Length > 0 ? ", " : "").Append(name).Append(':').Append(type); if (type == ShaderPropertyType.Texture) { sb.Append(_source.GetTexture(name) != null ? "=set" : "=empty"); } } string chosen = FindAlbedoProperty(_source); Texture chosenTexture = chosen != null ? _source.GetTexture(chosen) : null; string tint = ""; foreach (string hint in TintNameHints) { if (_source.HasProperty(hint)) { tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")"; break; } } string fade = ""; 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() : "")); Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "") + " -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "") + "; tint property: " + tint + "; fade property: " + fade + "; blend-mode properties present: " + canBlend); } } }