Rendering and Visuals
This chapter covers the rendering primitives in Anomaly.Client.Api.Rendering and the visual feature wrappers under Anomaly.Client.Api.Features. They are the building blocks for cosmetic mods — tinted materials, full-screen overlays, attached models, and animator-synchronised character writes.
All helpers in this chapter are static and stateless. The consumer owns the lifetime of any GameObject handles returned, and re-attaches across role changes when needed.
MaterialTint
Section titled “MaterialTint”Anomaly.Client.Api.Rendering.MaterialTint is a per-renderer HDRP_Lit tint primitive backed by a cached MaterialPropertyBlock. Use it to apply a colour multiply (and optionally a neutralised base map) without instantiating per-renderer material instances.
using Anomaly.Client.Api.Rendering;using UnityEngine;
// Simple _BaseColor multiply — best for materials whose base map is neutralMaterialTint.Apply(myRenderer, Color.cyan);
// _BaseColor + _BaseColorMap override — required when the base map has a strong castMaterialTint.Apply(jumpsuitRenderers, favoriteColor, neutralBaseMap);
// Drop every overrideMaterialTint.Clear(myRenderer);API:
public static void Apply(Renderer renderer, Color color);public static void Apply(IEnumerable<Renderer> renderers, Color color);public static void Apply(Renderer renderer, Color color, Texture neutralBaseMap);public static void Apply(IEnumerable<Renderer> renderers, Color color, Texture neutralBaseMap);public static void Clear(Renderer renderer);public static void Clear(IEnumerable<Renderer> renderers);Clear drops the entire MaterialPropertyBlock on the renderer. If another mod has set MPB overrides on the same renderer through any path, those overrides are dropped too — the API does not compose with other MPB users on the same renderer.
Worked example: Class D jumpsuit favourite colour
Section titled “Worked example: Class D jumpsuit favourite colour”The framework ships a per-player favourite-colour feature that tints every Class D jumpsuit with a player-chosen RGB value. This is the canonical worked example for the dual-overload pattern.
The color comes from MelonPreferences category anomaly, entry class_d_favorite_color (default #FFFFFF, format #RRGGBB). A favorite of #FFFFFF is treated as “no tint” - the framework calls MaterialTint.Clear instead of applying, so the original orange jumpsuit texture stays intact.
The local client sends its color on ClientEvents.Ready and after mid-game changes. The server keeps only an in-memory per-user cache and replays known player colors to new joiners, so late joins see the same Class D tints without disk persistence on the server.
Renderers are selected by walking Player.CharacterModel._renderers and filtering on gameObject.name.StartsWith("jumpsuit_LOD") — stable across LOD levels.
If a neutral base map is unavailable, the tracker degrades to _BaseColor-only tinting and logs a warning. Cool colours appear darker / blacker on the warm-cast stock texture; warm-palette selections still work. Bake the neutral base map offline and stage it under Anomaly.Client/Resources/PlayerCustomization/ if you want full hue range.
ScreenEffects
Section titled “ScreenEffects”Anomaly.Client.Api.Features.ScreenEffects drives a single full-screen UnityEngine.UI.Image parented under UiCanvas.GetLayer(UiLayer.Overlay). Use it for solid-colour tints (low-HP red overlay, faction flair, blackout frames) and one-shot flashes (flashbang-style hits, round-end flashes).
using Anomaly.Client.Api.Features;using UnityEngine;
// Persistent tint until cleared. Alpha controls opacity.ScreenEffects.Tint(new Color(1f, 0f, 0f, 0.25f));ScreenEffects.Tint(Color.red, 0.25f);
// One-shot animated flash — fades in, holds, fades out.ScreenEffects.Flash(Color.white, fadeIn: 0.05f, hold: 0.15f, fadeOut: 0.4f);
// Drop any active tint and any in-progress flashScreenEffects.ClearTint();API:
public static bool Tint(Color color);public static bool Tint(Color rgb, float alpha);public static bool ClearTint();public static bool Flash(Color color, float fadeIn = 0.1f, float hold = 0.2f, float fadeOut = 0.6f);The flash coroutine drives off Time.unscaledDeltaTime, so it animates correctly while Time.timeScale is paused.
Subtitles
Section titled “Subtitles”Anomaly.Client.Api.Features.Subtitles is a thin alias over LocalHint with subtitle-appropriate defaults — longer duration, fade-in / fade-out effects.
using Anomaly.Client.Api.Features;
Subtitles.Show("Containment breach detected.");Subtitles.Show("...this is line two.", durationSeconds: 6f);API:
public static bool Show(string text, float durationSeconds = 4f);public static bool Show(string text, HintEffect[] additionalEffects, float durationSeconds = 4f);The second overload composes caller-specified effects (typewriter, pulse, etc.) on top of the default fade. Both return false when the local hint display is not ready (e.g. before ClientEvents.Ready); the call is silently dropped.
ModelAttachment + PlayerAnchor
Section titled “ModelAttachment + PlayerAnchor”Anomaly.Client.Api.Rendering.ModelAttachment parents a GameObject under a named anchor on a player’s character model. Anomaly.Client.Api.Features.PlayerAnchor is the enum of supported attachment points; PlayerAnchors resolves an enum value to a Transform for a given player.
using Anomaly.Client.Api.Features;using Anomaly.Client.Api.Rendering;using UnityEngine;
// Resolve an anchor manuallyif (player.TryGetAnchor(PlayerAnchor.Back, out var backAnchor)){ var visual = ItemModels.InstantiateVisual(ItemType.Flashlight, backAnchor); visual.transform.localPosition = new Vector3(0, 0.1f, -0.1f);}
// Or let ModelAttachment do the resolve + instantiate + parent in one callvar flashlight = ModelAttachment.AttachPickup( ItemType.Flashlight, player, PlayerAnchor.Back, new Vector3(0, 0.1f, -0.1f), Quaternion.identity);
// Detach when the player leaves Class D, on disconnect, or on role changeModelAttachment.Detach(flashlight);ModelAttachment API:
public static bool Attach(GameObject model, Player player, PlayerAnchor anchor, Vector3 localPosition = default, Quaternion localRotation = default);public static bool AttachToTransform(GameObject model, Transform anchor, Vector3 localPosition = default, Quaternion localRotation = default);public static GameObject AttachPickup(ItemType type, Player player, PlayerAnchor anchor, Vector3 localPosition = default, Quaternion localRotation = default);public static void Detach(GameObject model, bool destroy = true);PlayerAnchors API:
public static Transform Get(Player player, PlayerAnchor anchor);public static bool TryGet(Player player, PlayerAnchor anchor, out Transform transform);public static Transform Get(CharacterModel model, PlayerAnchor anchor);public static bool TryGet(CharacterModel model, PlayerAnchor anchor, out Transform transform);Player also exposes convenience instance methods that delegate to the static resolver:
public Transform GetAnchor(PlayerAnchor anchor);public bool TryGetAnchor(PlayerAnchor anchor, out Transform transform);PlayerAnchor covers two groups:
- 25 standard Humanoid bones —
Hips,Spine,Chest,UpperChest,Neck,Head,LeftEye,RightEye,Jaw, the full arm chain (LeftShoulder…RightHand), the full leg chain (LeftUpperLeg…RightToes). - 13 SCP:SL-specific named-child mounts —
Back(between the shoulders),ChestRadio,ChestFilters,HeadTube,Eyewear(Scientist only),RightHandGun,RightHandGunHandle,RightHandGunHandle2(Guard / Scientist only),RightHandMag,RightHandPistolAmmo,RightHandRifleAmmo,RightHandItem(Guard only),RightThighHolster.
PlayerAnchors.Get returns null rather than throwing in four documented cases:
- The player has no
CharacterModel(SCP-079, dead spectator). - The model is not an
AnimatedCharacterModel(custom rigs like SCP-049 family). - The Humanoid bone is not present on this rig.
- The named child is absent on this role’s prefab.
Always null-check the result — role-restricted mounts (Eyewear, RightHandItem) return null outside their role.
default(Quaternion) is (0,0,0,0) — an invalid quaternion. Attach, AttachToTransform, and AttachPickup coerce a zero-quaternion to Quaternion.identity internally, so you can omit the rotation argument and get an upright child.
ItemModels
Section titled “ItemModels”Anomaly.Client.Api.Features.ItemModels exposes item visuals without needing a live world pickup. Backed by Il2CppInventorySystem.InventoryItemLoader.AvailableItems[ItemType].
using Anomaly.Client.Api.Features;
// Get the prefabif (ItemModels.TryGetPickupPrefab(ItemType.GunCom45, out var prefab)){ Debug.Log($"Renderers under prefab: {ItemModels.GetRenderers(ItemType.GunCom45).Count}");}
// Instantiate a visual-only copy (stripped of pickup / Mirror / Rigidbody components)var visual = ItemModels.InstantiateVisual(ItemType.Medkit, parent: someTransform);API:
public static ItemPickupBase GetPickupPrefab(ItemType type);public static GameObject GetPickupPrefabGameObject(ItemType type);public static bool TryGetPickupPrefab(ItemType type, out ItemPickupBase prefab);public static IReadOnlyList<Renderer> GetRenderers(ItemType type);public static GameObject InstantiateVisual(ItemType type, Transform parent = null);public static GameObject InstantiateVisual(GameObject pickupPrefab, Transform parent = null);InstantiateVisual strips the prefab to a pure visual GameObject:
- Destroys every
ItemPickupBase,Mirror.NetworkBehaviour,Mirror.NetworkIdentity, andRigidbody. - Disables every
Collider(kept for bounds introspection rather than destroyed). - Reparents to
parentwith local origin and identity rotation. - Does not call
NetworkServer.Spawn— there are no networking side effects.
GetRenderers returns an empty list when the prefab is missing, so callers don’t need null guards.
The live Pickup wrapper exposes the same shape for spawned pickups:
public IReadOnlyList<Renderer> Renderers { get; } // fresh each callpublic Transform VisualRoot { get; } // first child whose name ends with LOD/LODs/Mesh; cachedpublic GameObject InstantiateVisual(Transform parent = null); // delegate to ItemModelsCharacterModels and IModelHook
Section titled “CharacterModels and IModelHook”Anomaly.Client.Api.Features.CharacterModels lets mods write character-model state in sync with SCP:SL’s manual animator timing without reinventing the eight game-internal subtleties.
using Anomaly.Client.Api.Features;using Il2CppPlayerRoles.FirstPersonControl.Thirdperson;
public class MyHook : IModelHook{ public void OnBeforeAnimatorUpdated(AnimatedCharacterModel model) { // Write bone overrides here — runs before SCP:SL's animator pass. }
public void OnAnimatorUpdated(AnimatedCharacterModel model) { // Read final bone state here — runs after the animator pass. CharacterModels.SyncSecondaryRigs(model); }}
// Subscribe in OnInitializeMelon — auto-attaches to the local player's current model// and re-attaches across role changes. Returns IDisposable for cleanup.var handle = CharacterModels.RegisterLocalHook(new MyHook());API:
public static AnimatedCharacterModel LocalModel { get; }public static IDisposable RegisterLocalHook(IModelHook hook);public static int GetAnimatorLayerIndex(Animator animator, string layerName);public static bool SyncSecondaryRigs(AnimatedCharacterModel model);public static bool SuppressLookAt(AnimatedCharacterModel model, bool suppress);public static CullingSubcontroller GetCullingSubcontroller(AnimatedCharacterModel model);IModelHook:
public interface IModelHook{ void OnBeforeAnimatorUpdated(AnimatedCharacterModel model); void OnAnimatorUpdated(AnimatedCharacterModel model);}The helper methods encode hard-won game knowledge:
GetAnimatorLayerIndex— cached lookup so per-frame writes don’t pay string-hashing cost.SyncSecondaryRigs— callsSecondaryRigsSubcontroller.MatchAll()so mirrored / helper bones stay in sync after manual bone writes. Without it, the body flickers asymmetrically.SuppressLookAt— disablesLookatSubcontrollerso it doesn’t add chest / neck motion on top of your head writes.GetCullingSubcontroller— direct access for mods that need to readSkipAnimationUpdateetc.
What you need to know about the animator pipeline
Section titled “What you need to know about the animator pipeline”SCP:SL drives the animator manually from CullingSubcontroller.LateUpdate, not from Unity’s standard timing. Writes from a plain MonoBehaviour.LateUpdate either get clobbered by the next animator pass or fight the cycle. Implement IModelHook and use RegisterLocalHook.
The avatar already has "Left Hand Pose - (RefId=44175)" and "Right Hand Pose - (RefId=25842)" animator layers driven by LeftHandPose / RightHandPose time parameters. Override layer weights instead of fighting raw finger bones — cheaper, and survives locomotion animation.
HandPoseSubcontroller is event-driven (it fires from CullingSubcontroller.OnBeforeAnimatorUpdated), so disabling the component does not stop its writes. Override the hand-pose layer weights afterward.
FpcMouseLook.CurrentVertical uses opposite sign from world-space camera X pitch — negate when reading or writing pitch through MouseLook.
Don’t recenter from the animated head bone; walk animation moves it forward / back, dragging the model behind the camera. Cache a stable head anchor at Animator.GetBoneTransform(HumanBodyBones.Hips).parent (the primary armature root) instead.
For visual-only crouch, keep root correction in XZ only. Spread residual height delta through Spine / Chest / UpperChest / Neck. Moving the root down breaks leg animation.
HUD Output and Player State for hint, broadcast, and HUD-visibility helpers.