Console command helper that finds materials whose resource path matches a given fragment and dials their additive tint (g_vColorTint) for debugging; prints guidance for persisting the change into the original .vmt $color2. It walks active scene renderers and adjusts materials, with optional per-channel arguments.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Dial an additive material's brightness while looking at it. `nz_tint <match> <value>`.
///
/// ⛔ ON AN ADDITIVE SURFACE, BRIGHTNESS IS OPACITY. It adds to what is behind it, so there is no
/// separate transparency to turn down -- "more see-through" and "dimmer" are the same control, and
/// that control is $color2, which Source authored as a plain multiplier. Brya's Love states [5 5 5].
///
/// ⚠️ RUNTIME ONLY, and it edits the MATERIAL, which every object sharing it also sees. That is
/// exactly what makes it useful for dialling a value in and exactly why it must be written back to
/// the .vmt-generated .vmat afterwards rather than left as a session tweak.
/// </summary>
public static class MaterialTint
{
/// <summary>
/// `nz_tint nemesisstarglass 0.15` — set the colour tint on every loaded material whose path
/// matches. One value tints grey, three keep the hue.
///
/// ⛔ ONE VALUE CHANGES THE COLOUR, NOT JUST THE BRIGHTNESS, and on a tinted sight that is not
/// what anyone means by "more transparent". The Nemesis Star's glass states [0.3 0.3 0] -- zero
/// blue is what makes it read as yellow-green -- so `nz_tint nemesisstarglass 0.15` would push
/// blue up from 0 to 0.15 and wash the sight grey while appearing to do the right thing.
///
/// ⚠️ So r/g/b are separate, and g/b default to "same as r" only when they are not given at all.
/// `nz_tint reticle 1` therefore still means what it did.
/// </summary>
[ConCmd( "nz_tint" )]
public static void Set( string match = "", float value = -1f,
float g = float.NaN, float b = float.NaN )
{
if ( string.IsNullOrWhiteSpace( match ) )
{
Log.Info( "[tint] nz_tint <path fragment> <r> [g] [b]" );
Log.Info( "[tint] nz_tint reticle 1 grey/uniform" );
Log.Info( "[tint] nz_tint nemesisstarglass .15 .15 0 keeps the hue" );
return;
}
// ⚠️ NaN, not -1, as "not given" — 0 is a legitimate channel value and the Nemesis Star's
// blue is exactly 0, so a sentinel inside the valid range would be unusable here.
var gg = float.IsNaN( g ) ? value : g;
var bb = float.IsNaN( b ) ? value : b;
// ⚠️ Walk the RENDERERS in the scene rather than a material registry: what we want is the
// material actually on screen, and a path fragment is how a human refers to it.
var mats = Game.ActiveScene?.GetAllComponents<Renderer>()
.SelectMany( r => MaterialsOf( r ) )
.Where( m => m is not null && m.ResourcePath is not null
&& m.ResourcePath.Contains( match, System.StringComparison.OrdinalIgnoreCase ) )
.Distinct()
.ToList();
if ( mats is null || mats.Count == 0 )
{
Log.Info( $"[tint] nothing loaded matches '{match}'" );
return;
}
foreach ( var m in mats )
{
if ( value >= 0 )
m.Set( "g_vColorTint", new Vector4( value, gg, bb, 0f ) );
Log.Info( $"[tint] {m.ResourcePath}"
+ (value >= 0 ? $" -> g_vColorTint [{value:0.###} {gg:0.###} {bb:0.###} 0]" : "") );
}
// ⛔ PRINT THE .vmt LINE, NOT JUST THE VALUE. A tint dialled in here dies with the session,
// and the .vmat carries a "do not hand-edit" banner because the converter regenerates it --
// so the number has to go back into the SOURCE .vmt as $color2 or the next sweep erases it.
if ( value >= 0 )
Log.Info( $"[tint] {mats.Count} material(s) set. To keep it, put this in the .vmt:"
+ $" $color2 \"[{value:0.###} {gg:0.###} {bb:0.###}]\"" );
}
static System.Collections.Generic.IEnumerable<Material> MaterialsOf( Renderer r )
{
if ( r is ModelRenderer mr )
{
if ( mr.MaterialOverride is not null )
yield return mr.MaterialOverride;
var model = mr.Model;
if ( model is not null )
{
foreach ( var m in model.Materials )
yield return m;
}
}
}
}