Skip to content

Rendering Pipeline and Glow Implementation

A. Render pipeline identification

Stationeers uses Unity's built-in render pipeline on Unity 2021.2 LTS (same toolchain as ../Patterns/AsyncEnumerator472.md).

Key evidence:

  1. Bloom post-processing: the third-party UltimateBloom asset is attached via CameraEffectCollection.Bloom. The CameraController.SetBloom(bool) API toggles it. Enabled by default on vanilla gameplay cameras.
  2. No URP / HDRP: only Legacy Shaders and built-in-pipeline shader references appear in the decompile.
  3. Camera post-processing uses the old UnityStandardAssets.ImageEffects family, not the newer Post-Processing V2 stack.

UltimateBloom is what turns bright emissive pixels into a visible halo. Without bloom, an emissive material renders self-lit but produces no glow halo. Any glow feature for painted objects depends on the camera's bloom pass being on. The runtime read accessor is CameraController.Instance.CameraEffects[0].Bloom; check .enabled to confirm the component is active. See ../GameClasses/CameraController.md section "Singleton and effect collections" for the full accessor chain.

B. Shaders and emission on paintable objects

Every ColorSwatch in GameManager.CustomColors can ship up to two materials per color (see ../GameClasses/ColorSwatch.md):

  • ColorSwatch.Normal - standard diffuse, always set on live swatches.
  • ColorSwatch.Emissive - pre-baked emissive material, OPTIONAL: some swatches ship with Emissive == null and cannot be rendered in emissive mode via the material swap.

The game's shader system respects the _EmissionColor property. From the Thing decompile:

public static readonly int EMISSION_COLOR = Shader.PropertyToID("_EmissionColor");

EmissionColor = Color.white * (emissive ? 1f : 0f);
foreach (ThingRenderer renderer in Renderers)
{
    renderer.SetShaderVectorProperty(EMISSION_COLOR, EmissionColor);
}

At 0.2.6428.27798 only one of the two signals reaches the renderer: the material-swap (via CustomColorMapping.SetEmissive(material) on colors that have an Emissive variant). The shader property write stays on the ThingRenderer: SetShaderVectorProperty (329422-329425) only fills a private dictionary that nothing reads, FlushShaderProperties (329427-329429) is empty and never called, and the one other reader of Thing.EmissionColor, Structure.UpdateAllMaterialPropertyBlocks (320430-320444), is never called. The write still happens on every call, whether or not the swatch has an Emissive material, but nothing on screen changes because of it. The 0.2.6228.27061 reading said the game drives the renderer with both signals. Details on ColorSwatch, "Normal vs Emissive selection".

Mod code that wants to read the post-call renderer state goes through ThingRenderer.Materials / sharedMaterials / GetMaterial() on each entry in Thing.Renderers; see ../GameClasses/ThingRenderer.md for the accessor shapes and null-safety caveats.

Reading the emissive state back off a live Thing

There is a simpler read-back than walking the renderers, with limits. Thing.EmissionColor is a public instance field, declared public UnityEngine.Color EmissionColor = UnityEngine.Color.white; (323298), and only Thing.SetCustomColor(int index, bool emissive) writes it, assigning Color.white * (emissive ? 1f : 0f) (327945) just before handing it to every ThingRenderer, where it reaches no material (section B). SetCustomColor(bool emissive) swaps materials without touching it, and the int overload skips the write when it returns early on an invalid index or a missing swatch (327902, 327911). The field therefore records the emissive argument of the last SetCustomColor(int, bool) call on THIS machine:

EmissionColor Meaning
(1, 1, 1, 1) the last SetCustomColor(int, bool) on this machine passed emissive: true, or that overload has not written the field yet (see the initialiser trap below)
(0, 0, 0, 0) the last SetCustomColor(int, bool) on this machine passed emissive: false

That answers "is this Thing currently rendered emissive" only for a Thing whose glow goes through that overload alone, such as a painted structure a mod glows with SetCustomColor(index, true). No vanilla class works that way: lights switch their glow with SetCustomColor(bool). WallLight, Beacon, PortableLight, PortablePortal and StackableLight call it right after their base call (333693-333701, 346853-346861, 359277-359285, 359346-359354, 360277-360286), so a lit one can read (0, 0, 0, 0), and ChemLight and RoadFlare light themselves with SetCustomColor(emissive: true), which leaves the field as it was. The mismatch runs the other way too: a Diode recoloured while lit keeps (1, 1, 1, 1) after it is switched off, because the recolour goes through the int overload and the switch through the bool one (331566, 331570, 333773), and so does a LandingPadModular painted while flashing (188463, 188473, 188489). The 0.2.6403.27689 reading said the field answers the question for any Thing, with "the last SetCustomColor" in the table.

Two things make it the right probe for a multiplayer glow test. It is per-machine, so the same Thing can be sampled on a host and on a joined client and the two answers compared. And it is an ordinary instance field, so InspectorPlus can dump it by type without any mod-side instrumentation, which matters because a mod's own glow bookkeeping is usually a Dictionary that a static-only reflection endpoint cannot render.

The initialiser is a trap. The field starts at Color.white, not at zero, so a Thing on which SetCustomColor has never run reads (1, 1, 1, 1) and looks glowing. Only read it on a Thing that has been painted at least once. Measured live on 0.2.6403.27689: a freshly spawned ItemSprayGun reported (1, 1, 1, 1) while never having been painted, and two freshly built pipes reported (0, 0, 0, 0) as soon as a spray can had touched them.

Thing.DiffuseIndex sits next to it and is assigned index on the same code path, so a single InspectorPlus request for ReferenceId, DiffuseIndex and EmissionColor reports colour and glow together.

C. How existing glowing things work in-game

At 0.2.6428.27798 vanilla glow is the ColorSwatch.Emissive material swap alone, and many classes use it, not only chemical lights and road flares: wall lights, diodes and flashing lights, beacons, portable lights and portals, and landing pad lights (full list in ../GameClasses/ColorSwatch.md section "Vanilla callers of SetCustomColor with emissive: true"). When a chemical light or road flare is switched on, OnInteractableUpdated calls SetCustomColor(emissive: true) (348410, 360342), the overload that swaps in the Emissive material (if present) without touching EmissionColor. UltimateBloom renders the halo. The 0.2.6228.27061 reading limited this to ChemLights and RoadFlares, with a call to SetCustomColor(index, emissive: true) that also set _EmissionColor = Color.white.

The next two sentences are the 0.2.6228.27061 reading and were not re-checked at 0.2.6428.27798. No per-object point lights are used for this effect. The halo is entirely shader-driven.

Painted structures (walls, pipes, cables, frames) never receive emissive: true in vanilla. The API supports it but no caller invokes it; that is the gap SprayPaintPlus would fill.

D. Mod prior art: PowerTransmitterPlus beam visuals

PowerTransmitterPlus renders its power beam with a LineRenderer using Legacy Shaders/Particles/Additive:

var shader = Shader.Find("Legacy Shaders/Particles/Additive")
           ?? Shader.Find("Particles/Additive")
           ?? Shader.Find("Sprites/Default")
           ?? Shader.Find("Hidden/Internal-Colored");

Additive shading produces bloom-friendly brightness, so the beam appears to glow under UltimateBloom. Beam color is set via startColor / endColor; no _EmissionColor manipulation is used. This is a proof point that runtime-created additive materials work under the game's rendering and bloom into a visible halo.

E. Material-per-instance pattern

For per-instance material mutation (glow intensity per-Thing, custom emission color per-paint), follow ../Patterns/UnityMaterialPerInstance.md:

  1. Read renderer.material once to trigger the clone; assignment to subsequent renderers on the same GameObject can share.
  2. Cache the cloned Material reference (e.g. keyed by Thing.ReferenceId).
  3. Mutate via SetColor("_EmissionColor", ...), EnableKeyword("_EMISSION"), etc.
  4. Destroy(material) in OnDestroy or the clone leaks.

F. The practical decision: how to implement glow

Three viable techniques, in order of preference:

  1. Call vanilla Thing.SetCustomColor(index, emissive: true). Cleanest; uses the existing API; swaps to Emissive material. At 0.2.6428.27798 the _EmissionColor it also sets reaches no material (section B), so the swap is the whole effect. Limitations: the flag is transient and re-enters on every color change / sync / load, so a mod must re-apply after every vanilla SetCustomColor call. Swatches whose Emissive is null get no material swap and therefore no glow at all from this path: the _EmissionColor write never reaches the Normal material's shader. The 0.2.6228.27061 reading said such swatches may or may not glow depending on whether the Normal material's shader honors the property.

  2. Per-instance material clone + direct _EmissionColor write. Bypass SetCustomColor entirely on the glow side: fetch renderer.material once, call SetColor("_EmissionColor", CustomColor.Color * intensity). Gives full intensity control, works for swatches that have null Emissive as long as their Normal shader declares _EmissionColor. Costs one cloned material per glowing Thing; cleanup per ../Patterns/UnityMaterialPerInstance.md.

  3. Shader swap to additive (fallback). If neither (1) nor (2) produces visible glow on some Things (batched structures, custom-shader children), mirror PowerTransmitterPlus's approach: create a new Material with Shader.Find("Legacy Shaders/Particles/Additive") and swap. Breaks normal diffuse lighting in lit areas, so reserve for cases where the Thing is only visible when glow matters.

What will NOT work:

  • Encoding glow as a high bit in the color index. GameManager.IsValidColor clamps; see ../Protocols/ThingColorMessage.md.
  • Attaching per-object Light components. TODO-flagged as unacceptable for network-painting paint counts.
  • Enabling the _EMISSION shader keyword without also ensuring the material variant exposes the keyword. On Unity Standard, EnableKeyword("_EMISSION") + SetColor("_EmissionColor", c) both are required; on a custom shader the keyword may be redundant or not present at all. Probe first, don't assume.

G. Persistence and multiplayer gaps

Vanilla's emissive parameter is transient (see ../GameClasses/ColorSwatch.md). A mod feature that wants glow to survive save/load and multiplayer must:

  1. Store an IsGlowing flag per Thing (custom ThingSaveData subclass; see ../Patterns/SaveDataIsinstInheritance.md and SaveDataRegistration.md).
  2. Sync the flag via a free Thing.NetworkUpdateFlags bit + postfixes on Thing.BuildUpdate / ProcessUpdate / SerializeOnJoin / DeserializeOnJoin (pattern per Mods/SprayPaintPlus/RESEARCH.md section 3.4, applied one level up on Thing).
  3. Re-apply the emissive effect after every SetCustomColor postfix for Things whose flag is set, since the vanilla sync and load paths clear it.

The re-apply hook is what makes the feature work: any path that mutates color (paint, the colour message on the server, a flag 32 update or join record on a client, save load) passes emissive: false to the base SetCustomColor(int, bool), which clears the glow on every class that does not put its own emission back afterwards, so a postfix must read the glow flag and re-apply. Vanilla light classes are the exception: WallLight, Beacon, PortableLight, PortablePortal, StackableLight, LandingPadModular and GrowLight re-apply their own emission after the base call (see ../GameClasses/ColorSwatch.md, "Any re-entry reverts, unless the class re-applies emission"). The 0.2.6228.27061 reading said every such path clobbers the glow.

H. Visibility proof recipe

To verify glow is visible in-game, use the probe plugin at Plans/GlowPaintProbe/:

  1. Apply emissive to a painted pipe via direct SetCustomColor(index, emissive: true) or via per-instance _EmissionColor write.
  2. Place the pipe in a dark room (no external lights).
  3. A visible halo around the pipe confirms the bloom path is live.
  4. In a lit room, the glow is a subtle brightness lift from the bloom pass.

If step 3 produces no halo, check:

  • Is CameraController.SetBloom enabled? Confirm via the probe plugin's bloom log line (the runtime read accessor is documented in ../GameClasses/CameraController.md).
  • Does CustomColor.Emissive exist for the swatch? Confirm via the probe plugin's swatch enumeration (which logs emissive=yes|no per entry).
  • Does the pipe's mesh have a ThingRenderer that was enumerated by SetCustomColor? Thing.Renderers is a public list; iterate and log via the probe's F9 handler (see ../GameClasses/ThingRenderer.md).

Verification history

  • 2026-04-21: page created from decompiled Assembly-CSharp.dll and PowerTransmitterPlus source.
  • 2026-04-21: corrected section C. Original claim "Every color has both Normal and Emissive materials" was not accurate; vanilla code contains if (CustomColor.Emissive == null) null-checks, so Emissive is optional per swatch. Page now links to ../GameClasses/ColorSwatch.md which documents the class fully, and section F distinguishes the two glow paths (material swap vs. property write) based on whether Emissive is present. Also restamped section formatting to the <!-- verified: ... --> HTML-comment form required by Research/CLAUDE.md.
  • 2026-04-21: added runtime read accessor chain for bloom to section A (CameraController.Instance.CameraEffects[0].Bloom with .enabled check) and cross-references to the new ../GameClasses/CameraController.md and ../GameClasses/ThingRenderer.md pages. Section H updated to reference the Plans/GlowPaintProbe/ probe plugin now that InspectorPlus is off-limits. Additive; no prior claim changed.
  • 2026-04-21: resolved the "fraction of swatches with non-null Emissive" open question via GlowPaintProbe plugin logs. All 12 shipping swatches in v0.2.6228.27061 carry non-null Normal and Emissive materials. Full inventory (names + presence) is documented in ../GameClasses/ColorSwatch.md section "Vanilla swatch inventory (v0.2.6228.27061)". Approach F.1 is therefore viable for every vanilla paint color; approach F.2 only matters for mod-added swatches that leave Emissive null.
  • 2026-08-09: added the "Reading the emissive state back off a live Thing" subsection to section B, from the 0.2.6403.27689 decompile of Assets.Scripts.Objects.Thing (the public UnityEngine.Color EmissionColor = UnityEngine.Color.white field declaration and its assignment inside SetCustomColor(int, bool)), cross-checked live against two rig instances during the Spray Paint Plus glow playtest. Additive: no prior claim changed, and the EmissionColor code the section builds on was already quoted in section B. Page verified_in / verified_at moved to 0.2.6403.27689 / 2026-08-09 for the new subsection only; every other section keeps its 0.2.6228.27061 stamp and was not re-read this pass.
  • 2026-04-21: approach F.1 visually and programmatically confirmed via GlowPaintProbe. Calling Thing.SetCustomColor(index, emissive: true) on a painted Piping instance in a dark room produces a visible bloom halo; Plans/GlowPaintProbe/ log lines captured the runtime material swap from TextureArrayColorSwatch (shader Custom/StandardTextureArray) to ColorPurpleEmissive (shader StandardInstanced) with _EmissionColor=(1.051, 0, 2.290, 1) and _EMISSION=on. Reverts cleanly on emissive: false. Full details in ../GameClasses/ColorSwatch.md section "Vanilla swatch material naming (runtime)". F.1 is green-lit for the SprayPaintPlus glow-paint implementation.
  • 2026-09-21 (game version 0.2.6428.27798): two conflicts, each with a fresh validator whose verdict is recorded in full on ColorSwatch. (1) Section B said "On every SetCustomColor call, the game drives the renderer with two independent signals: the material-swap (via CustomColorMapping.SetEmissive(material) on colors that have an Emissive variant) and the shader property write (_EmissionColor on every ThingRenderer). The property write happens unconditionally regardless of whether the swatch has an Emissive material."; section F.1 said swatches with a null Emissive "produce no material swap (only the _EmissionColor write), which may or may not glow depending on whether the Normal material's shader honors the property". New finding: the _EmissionColor value reaches no material at 0.2.6428.27798. Verdict: B is correct. (2) Section C said "Vanilla uses the ColorSwatch.Emissive + _EmissionColor approach for ChemLights and RoadFlares only" and that a flare calls "SetCustomColor(index, emissive: true)". New finding: many light classes pass emissive: true, and chemical lights and road flares use the SetCustomColor(bool) overload, which never touches EmissionColor. Verdict: Neither, with a corrected census. Result: the B paragraph, the first C paragraph and the F.1 item rewritten under inline 0.2.6428.27798 stamps, each ending with the 0.2.6228.27061 reading; the painted-structure paragraph in C re-verified and stamped; the paragraph between them marked as the unchecked 0.2.6228.27061 reading. Section stamps stay at 0.2.6228.27061. Bumped frontmatter verified_in / verified_at and added a sources line.
  • 2026-09-21 (second pass, game version 0.2.6428.27798): two more corrections. (1) Section G said "any path that mutates color (paint, message receive, save load) passes emissive: false and clobbers the glow". This is the claim corrected on ColorSwatch under the fresh validator verdict of the earlier 2026-09-21 pass (B is correct: seven light classes re-apply emission after the base call), and the colour message runs on the server, per the direction verdict on ThingColorMessage; that verdict was reused, not re-run. (2) "Reading the emissive state back off a live Thing" said EmissionColor answers "is this Thing currently rendered emissive on THIS machine", with "the last SetCustomColor" in its table. New finding: only SetCustomColor(int, bool) writes the field, so it tracks that overload alone, and lights that switch glow with SetCustomColor(bool) read it wrongly in both directions. Fresh validator verdict: B is correct, quoting the SetCustomColor(bool) body (327954-327974), which switches to the Emissive material without writing EmissionColor; it found the one writer at 327945, no save or sync of the value, and the Diode and LandingPadModular cases that keep (1, 1, 1, 1) while dark. Result: the section G paragraph and the subsection's opening paragraph and table rewritten under inline 0.2.6428.27798 stamps, with a new paragraph on where the field misleads; the initialiser paragraph re-scoped to its 0.2.6403.27689 stamp. The subsection stamp stays at 0.2.6403.27689. Also added an Open question that was raised but not checked (point lights and lens flares). Added a sources line.

Open questions

  • Does setting _EmissionColor alone on the shared Normal material (approach F.2 without the material swap) produce visible glow? The Normal shader is Custom/StandardTextureArray (a custom shader); whether it honors the property without the _EMISSION keyword is unknown. The Normal material is shared across all painted Things, so this path would need a per-instance material clone (see ../Patterns/UnityMaterialPerInstance.md). Relevant only for mod-added swatches whose Emissive is null, since every vanilla swatch ships both.
  • Where is UltimateBloom actually attached at runtime? GlowPaintProbe observed CameraController.Instance.CameraEffects.Count == 0 throughout gameplay, yet bloom halo is visibly active on emissive materials. The decompile-derived accessor CameraController.Instance.CameraEffects[0].Bloom returns empty; see ../GameClasses/CameraController.md section "Runtime attachment (unresolved)". Not blocking for F.1 (bloom is working), but worth identifying before shipping a feature that needs to validate "bloom is on" from code.
  • Does section C's "No per-object point lights are used for this effect. The halo is entirely shader-driven." still hold at 0.2.6428.27798? Raised, not checked, during the 2026-09-21 correction pass: flares carry a FlareLight light (StackableLight), and Thing.SetCustomColor(int, bool) sets the colour of each LOD lens flare (lodFlares), so lights and lens flares may take part in the glow.
  • Does the initialiser-trap paragraph in "Reading the emissive state back" hold for every Thing? Two reports from the 2026-09-21 correction pass, neither checked: EmissionColor is a serialized field, so a live Thing starts from its prefab's stored value and Color.white is only the code default; and Frame and Wall call SetCustomColor(CustomColor.Index) from Awake (decompile 150586 and 329897), so unpainted ones would read (0,0,0,0) from spawn rather than (1,1,1,1). The paragraph also says SetCustomColor where it means the (int, bool) overload. One validated edit should settle all three.