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:
- Bloom post-processing: the third-party UltimateBloom asset is attached via
CameraEffectCollection.Bloom. TheCameraController.SetBloom(bool)API toggles it. Enabled by default on vanilla gameplay cameras. - No URP / HDRP: only Legacy Shaders and built-in-pipeline shader references appear in the decompile.
- Camera post-processing uses the old
UnityStandardAssets.ImageEffectsfamily, 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 withEmissive == nulland 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:
- Read
renderer.materialonce to trigger the clone; assignment to subsequent renderers on the same GameObject can share. - Cache the cloned
Materialreference (e.g. keyed byThing.ReferenceId). - Mutate via
SetColor("_EmissionColor", ...),EnableKeyword("_EMISSION"), etc. Destroy(material)inOnDestroyor the clone leaks.
F. The practical decision: how to implement glow¶
Three viable techniques, in order of preference:
-
Call vanilla
Thing.SetCustomColor(index, emissive: true). Cleanest; uses the existing API; swaps toEmissivematerial. At 0.2.6428.27798 the_EmissionColorit 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 vanillaSetCustomColorcall. Swatches whoseEmissiveis null get no material swap and therefore no glow at all from this path: the_EmissionColorwrite never reaches theNormalmaterial's shader. The 0.2.6228.27061 reading said such swatches may or may not glow depending on whether theNormalmaterial's shader honors the property. -
Per-instance material clone + direct
_EmissionColorwrite. BypassSetCustomColorentirely on the glow side: fetchrenderer.materialonce, callSetColor("_EmissionColor", CustomColor.Color * intensity). Gives full intensity control, works for swatches that have nullEmissiveas long as theirNormalshader declares_EmissionColor. Costs one cloned material per glowing Thing; cleanup per../Patterns/UnityMaterialPerInstance.md. -
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
MaterialwithShader.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.IsValidColorclamps; see../Protocols/ThingColorMessage.md. - Attaching per-object
Lightcomponents. TODO-flagged as unacceptable for network-painting paint counts. - Enabling the
_EMISSIONshader 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:
- Store an
IsGlowingflag per Thing (customThingSaveDatasubclass; see../Patterns/SaveDataIsinstInheritance.mdandSaveDataRegistration.md). - Sync the flag via a free
Thing.NetworkUpdateFlagsbit + postfixes onThing.BuildUpdate/ProcessUpdate/SerializeOnJoin/DeserializeOnJoin(pattern perMods/SprayPaintPlus/RESEARCH.mdsection 3.4, applied one level up onThing). - Re-apply the emissive effect after every
SetCustomColorpostfix 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/:
- Apply emissive to a painted pipe via direct
SetCustomColor(index, emissive: true)or via per-instance_EmissionColorwrite. - Place the pipe in a dark room (no external lights).
- A visible halo around the pipe confirms the bloom path is live.
- In a lit room, the glow is a subtle brightness lift from the bloom pass.
If step 3 produces no halo, check:
- Is
CameraController.SetBloomenabled? Confirm via the probe plugin's bloom log line (the runtime read accessor is documented in../GameClasses/CameraController.md). - Does
CustomColor.Emissiveexist for the swatch? Confirm via the probe plugin's swatch enumeration (which logsemissive=yes|noper entry). - Does the pipe's mesh have a
ThingRendererthat was enumerated bySetCustomColor?Thing.Renderersis 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, soEmissiveis optional per swatch. Page now links to../GameClasses/ColorSwatch.mdwhich documents the class fully, and section F distinguishes the two glow paths (material swap vs. property write) based on whetherEmissiveis present. Also restamped section formatting to the<!-- verified: ... -->HTML-comment form required byResearch/CLAUDE.md. - 2026-04-21: added runtime read accessor chain for bloom to section A (
CameraController.Instance.CameraEffects[0].Bloomwith.enabledcheck) and cross-references to the new../GameClasses/CameraController.mdand../GameClasses/ThingRenderer.mdpages. Section H updated to reference thePlans/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-nullNormalandEmissivematerials. Full inventory (names + presence) is documented in../GameClasses/ColorSwatch.mdsection "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 leaveEmissivenull. - 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(thepublic UnityEngine.Color EmissionColor = UnityEngine.Color.whitefield declaration and its assignment insideSetCustomColor(int, bool)), cross-checked live against two rig instances during the Spray Paint Plus glow playtest. Additive: no prior claim changed, and theEmissionColorcode the section builds on was already quoted in section B. Pageverified_in/verified_atmoved 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 paintedPipinginstance in a dark room produces a visible bloom halo;Plans/GlowPaintProbe/log lines captured the runtime material swap fromTextureArrayColorSwatch(shaderCustom/StandardTextureArray) toColorPurpleEmissive(shaderStandardInstanced) with_EmissionColor=(1.051, 0, 2.290, 1)and_EMISSION=on. Reverts cleanly onemissive: false. Full details in../GameClasses/ColorSwatch.mdsection "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
SetCustomColorcall, the game drives the renderer with two independent signals: the material-swap (viaCustomColorMapping.SetEmissive(material)on colors that have anEmissivevariant) and the shader property write (_EmissionColoron everyThingRenderer). The property write happens unconditionally regardless of whether the swatch has anEmissivematerial."; section F.1 said swatches with a nullEmissive"produce no material swap (only the_EmissionColorwrite), which may or may not glow depending on whether theNormalmaterial's shader honors the property". New finding: the_EmissionColorvalue reaches no material at 0.2.6428.27798. Verdict: B is correct. (2) Section C said "Vanilla uses theColorSwatch.Emissive+_EmissionColorapproach for ChemLights and RoadFlares only" and that a flare calls "SetCustomColor(index, emissive: true)". New finding: many light classes passemissive: true, and chemical lights and road flares use theSetCustomColor(bool)overload, which never touchesEmissionColor. 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: falseand 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" saidEmissionColoranswers "is this Thing currently rendered emissive on THIS machine", with "the lastSetCustomColor" in its table. New finding: onlySetCustomColor(int, bool)writes the field, so it tracks that overload alone, and lights that switch glow withSetCustomColor(bool)read it wrongly in both directions. Fresh validator verdict: B is correct, quoting theSetCustomColor(bool)body (327954-327974), which switches to the Emissive material without writingEmissionColor; it found the one writer at 327945, no save or sync of the value, and theDiodeandLandingPadModularcases 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
_EmissionColoralone on the sharedNormalmaterial (approach F.2 without the material swap) produce visible glow? The Normal shader isCustom/StandardTextureArray(a custom shader); whether it honors the property without the_EMISSIONkeyword 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 whoseEmissiveis null, since every vanilla swatch ships both. - Where is
UltimateBloomactually attached at runtime? GlowPaintProbe observedCameraController.Instance.CameraEffects.Count == 0throughout gameplay, yet bloom halo is visibly active on emissive materials. The decompile-derived accessorCameraController.Instance.CameraEffects[0].Bloomreturns empty; see../GameClasses/CameraController.mdsection "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
FlareLightlight (StackableLight), andThing.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:
EmissionColoris a serialized field, so a live Thing starts from its prefab's stored value andColor.whiteis only the code default; andFrameandWallcallSetCustomColor(CustomColor.Index)fromAwake(decompile 150586 and 329897), so unpainted ones would read(0,0,0,0)from spawn rather than(1,1,1,1). The paragraph also saysSetCustomColorwhere it means the(int, bool)overload. One validated edit should settle all three.