diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
index 7ee7166..1c5eb62 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
@@ -46,6 +46,7 @@ public partial class MeshPainter
///
private void ReseedCanonical(Color32[][] canonical)
{
+ _backendSeeded = true; // the backend is about to hold real content (fast-path reseed or full rebuild)
if (_setupValid)
{
if (useGpu && _gpuActive && _gpu != null) { ReseedGpuInPlace(canonical); _hasLastScreen = false; return; }
@@ -54,6 +55,17 @@ public partial class MeshPainter
RebuildBackend(useGpu, canonical);
}
+ ///
+ /// Materializes a DEFERRED backend's base seed on demand: a remote replica that skipped its Start seed
+ /// (see ) must fill the base BEFORE a replicated stamp lands on it or it is
+ /// captured, so nothing paints over / serves uninitialized pixels. No-op once seeded (the normal case: the
+ /// snapshot apply already re-seeded it). A null canonical resolves each map to the material seed.
+ ///
+ private void EnsureBackendSeeded()
+ {
+ if (!_backendSeeded) ReseedCanonical(null);
+ }
+
/// Tears down the active backend and sets up the requested one on the given maps.
private void RebuildBackend(bool gpu, Color32[][] canonical)
{
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
index 1ab5c2d..8885fe5 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
@@ -6,6 +6,7 @@ public partial class MeshPainter
private void SetupCpu(Color32[][] canonical)
{
+ int n = textureSize * textureSize;
_cpu = new CpuMap[MAP_COUNT];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
@@ -17,15 +18,20 @@ public partial class MeshPainter
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
},
- pixels = SeedCpuPixels(mi, canonical),
- // UV-island seed protects loaded/switched-in content from edge padding right away
- // (the gutter stays paddable). Clone: painted[] is mutated per stroke.
- painted = (bool[])GetIslandMask().Clone(),
- fillStamp = new int[textureSize * textureSize],
+ // Deferred (remote replica): allocate empty buffers and skip the seed + upload; the snapshot
+ // re-seed fills them before the reveal gate shows the body. Otherwise seed now. The UV-island
+ // seed protects loaded/switched-in content from edge padding right away (the gutter stays
+ // paddable). Clone: painted[] is mutated per stroke.
+ pixels = DeferInitialSeed ? new Color32[n] : SeedCpuPixels(mi, canonical),
+ painted = DeferInitialSeed ? new bool[n] : (bool[])GetIslandMask().Clone(),
+ fillStamp = new int[n],
dilateStamp = 0
};
- cm.tex.SetPixels32(cm.pixels);
- cm.tex.Apply(false);
+ if (!DeferInitialSeed)
+ {
+ cm.tex.SetPixels32(cm.pixels);
+ cm.tex.Apply(false);
+ }
_cpu[mi] = cm;
AssignMapTexture(mi, cm.tex);
}
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
index fac0d68..12de2fd 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
@@ -25,6 +25,26 @@ public partial class MeshPainter
private Material _packMat;
private bool _prevSRGB;
+ // --- Shared backend resource pools ---------------------------------------------------------------
+ // Every paint RT has the identical descriptor (textureSize² ARGBHalf linear), so a despawned avatar's
+ // freed RTs can be reused by the next spawn instead of re-allocating ~70 MB. This closes the gap the
+ // in-place reseed left: reseed reuses an EXISTING backend, but a Conversion hit (hider despawns ->
+ // hunter respawns) tears one down and builds another - the observer stalled on that full free+alloc.
+ // Pooling reclaims the just-freed set immediately. Bounded so a mass despawn (match end) does not park
+ // every avatar's RTs; extras beyond the cap are released normally. Static, to survive the despawn->
+ // respawn gap; flushed on quit. Unity's null-check safely skips any entry a fast-enter-playmode reload
+ // left dangling.
+ private static readonly System.Collections.Generic.Stack s_rtPool =
+ new System.Collections.Generic.Stack();
+ private const int RtPoolCap = 18; // ~2 avatars' worth (9 RTs each)
+ private static bool s_poolQuitHooked;
+
+ // Reused staging textures for seed uploads (one per colour space), so re-seeding on every spawn/apply
+ // does not churn a 4 MB Texture2D per map. The linear flag must match the data (colour maps decode sRGB
+ // on the blit), so the two spaces are kept apart; rebuilt if the paint size ever changes.
+ private static Texture2D s_stageLinear;
+ private static Texture2D s_stageSRGB;
+
///
/// Sets up the GPU backend (RenderTexture set + materials per map). Returns true on
/// success; on missing shaders it falls back to the CPU backend and returns false so
@@ -62,7 +82,7 @@ public partial class MeshPainter
};
_gpu[mi] = gm;
- SeedGpuMap(mi, canonical);
+ if (!DeferInitialSeed) SeedGpuMap(mi, canonical); // deferred: RTs stay unseeded until the snapshot re-seed
// Color maps render to the display RT; the MS map exposes its packed final RT.
AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
@@ -149,9 +169,9 @@ public partial class MeshPainter
foreach (var gm in _gpu)
{
if (gm == null) continue;
- if (gm.display) { gm.display.Release(); Destroy(gm.display); }
- if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
- if (gm.final) { gm.final.Release(); Destroy(gm.final); }
+ RecycleRT(gm.display);
+ RecycleRT(gm.temp);
+ RecycleRT(gm.final);
}
_gpu = null;
}
@@ -160,8 +180,45 @@ public partial class MeshPainter
if (_packMat) { Destroy(_packMat); _packMat = null; }
}
+ // Returns an RT to the shared pool for the next spawn to reuse, or releases it if the pool is full.
+ // Deliberately NOT Released before pooling - Release frees the GPU memory the reuse is meant to keep.
+ private static void RecycleRT(RenderTexture rt)
+ {
+ if (!rt) return;
+ if (s_rtPool.Count < RtPoolCap) s_rtPool.Push(rt);
+ else { rt.Release(); Object.Destroy(rt); }
+ }
+
+ // Flushes the pooled RTs + staging textures. Hooked to Application.quitting (does not fire on editor
+ // play-mode stop, so the capped set lingers until the next domain reload there - a bounded ~150 MB).
+ private static void ClearBackendPool()
+ {
+ while (s_rtPool.Count > 0)
+ {
+ var rt = s_rtPool.Pop();
+ if (rt) { rt.Release(); Object.Destroy(rt); }
+ }
+ if (s_stageLinear) { Object.Destroy(s_stageLinear); s_stageLinear = null; }
+ if (s_stageSRGB) { Object.Destroy(s_stageSRGB); s_stageSRGB = null; }
+ }
+
private RenderTexture CreateRT(string rtName)
{
+ // Reuse a pooled RT of the matching size before allocating (see s_rtPool). A pooled RT is still
+ // Created (never Released while pooled); its stale content is fully overwritten by SeedGpuMap.
+ while (s_rtPool.Count > 0)
+ {
+ var pooled = s_rtPool.Pop();
+ if (pooled && pooled.width == textureSize && pooled.height == textureSize)
+ {
+ pooled.name = rtName;
+ return pooled;
+ }
+ if (pooled) { pooled.Release(); Destroy(pooled); } // size mismatch / stale -> drop it
+ }
+
+ if (!s_poolQuitHooked) { Application.quitting += ClearBackendPool; s_poolQuitHooked = true; }
+
// 16-bit half-float, NOT ARGB32: repeated low-opacity source-over only converges to the
// target colour if the accumulation buffer has sub-8-bit precision. At 8 bpp the per-stroke
// decrement dst*alpha rounds back to dst once it drops below half an LSB, so painting e.g.
@@ -189,7 +246,7 @@ public partial class MeshPainter
/// zero alpha is correct there too.
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear)
{
- var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
+ var tmp = AcquireStage(linear); // reused per colour space, not allocated per call
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
tmp.SetPixels32(colors);
tmp.Apply(false);
@@ -200,7 +257,25 @@ public partial class MeshPainter
GL.sRGBWrite = false;
Graphics.Blit(tmp, rt);
GL.sRGBWrite = prev;
- Destroy(tmp);
+ }
+
+ /// A reusable staging Texture2D per colour space (see s_stageLinear/s_stageSRGB): seeding uploads
+ /// through it instead of churning a 4 MB texture per map. The linear flag must match the data (colour maps
+ /// decode sRGB when the blit samples the staging tex), so the two spaces are kept separate; rebuilt if the
+ /// paint size ever changes. Main-thread only, so the shared instances never overlap.
+ private Texture2D AcquireStage(bool linear)
+ {
+ ref Texture2D slot = ref linear ? ref s_stageLinear : ref s_stageSRGB;
+ if (!slot || slot.width != textureSize || slot.height != textureSize)
+ {
+ if (slot) Destroy(slot);
+ slot = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear)
+ {
+ name = linear ? "MeshPainter_StageLinear" : "MeshPainter_StageSRGB",
+ hideFlags = HideFlags.HideAndDontSave
+ };
+ }
+ return slot;
}
///
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs
index b97b53a..d26f897 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs
@@ -125,6 +125,7 @@ public partial class MeshPainter
public void ApplyNetworkStamp(in PaintStampNet s)
{
if (!_setupValid) return;
+ EnsureBackendSeeded(); // a stamp arriving before the first snapshot re-seed must land on the base seed
paintAlbedo = (s.Channels & PaintStampNet.FlagAlbedo) != 0;
paintMetallicSmoothness = (s.Channels & PaintStampNet.FlagMetallicSmoothness) != 0;
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs
index 55956a6..01747e0 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs
@@ -350,7 +350,9 @@ public partial class MeshPainter
var bytes = new byte[n * 4];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
- Color32[] px = ReadCanonical(mi);
+ // A deferred backend not yet re-seeded holds uninitialized RTs; serve the material seed instead
+ // so a snapshot captured in that window is the base look (consistent with its cutoff), not garbage.
+ Color32[] px = _backendSeeded ? ReadCanonical(mi) : _seedCanonical[mi];
int count = Mathf.Min(n, px.Length);
for (int i = 0; i < count; i++)
{
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs
index cd98e97..492cee0 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs
@@ -293,4 +293,22 @@ public partial class MeshPainter
c.a = a;
_materialInstance.SetColor(XRayColorId, c);
}
+
+ /// Applies the current hit-flash state to the X-ray material: flash mode boosts the authored glow
+ /// rgb by xrayFlashBoost, turns the breathing pulse off (steady), and snaps the reveal alpha on for an
+ /// immediate pop; leaving flash restores the authored rgb + pulse (alpha keeps easing via TickXRayFade). The
+ /// pulse plateau otherwise leaves a short hit window looking dim, so a steady boosted glow reads far stronger.
+ private void ApplyXRayFlashState()
+ {
+ if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return;
+ float boost = _xrayFlash ? Mathf.Max(1f, xrayFlashBoost) : 1f;
+ Color c = _materialInstance.GetColor(XRayColorId);
+ c.r = _xrayBaseColor.r * boost;
+ c.g = _xrayBaseColor.g * boost;
+ c.b = _xrayBaseColor.b * boost;
+ if (_xrayFlash) c.a = _xrayTarget; // snap the glow on so the hit reads instantly (no ramp-in)
+ _materialInstance.SetColor(XRayColorId, c);
+ if (_materialInstance.HasProperty(XRayPulseStrengthId))
+ _materialInstance.SetFloat(XRayPulseStrengthId, _xrayFlash ? 0f : _xrayBasePulseStrength);
+ }
}
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
index 17683f9..b0e05c2 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
@@ -126,13 +126,30 @@ public partial class MeshPainter : MonoBehaviour
public bool XRayRevealed
{
get => _xrayTarget > 0f;
- set => _xrayTarget = value ? 1f : 0f;
+ set => SetXRayReveal(value, false);
+ }
+
+ /// Drives the X-ray reveal with an optional hunter-hit mode: the flash
+ /// multiplies the authored glow by and disables the breathing pulse, so a hit
+ /// reads as a strong, solid pop (the subtle breathing plateau otherwise leaves a short hit window looking
+ /// dim). Leaving flash mode restores the authored colour + pulse. Only writes the material on a change.
+ public void SetXRayReveal(bool revealed, bool flash)
+ {
+ _xrayTarget = revealed ? 1f : 0f;
+ if (flash != _xrayFlash) { _xrayFlash = flash; ApplyXRayFlashState(); }
}
[Tooltip("Seconds for the see-through X-ray reveal glow to fade fully in or out when toggled (0 = instant snap).")]
[Min(0f)]
[SerializeField] private float xrayFadeDuration = 0.3f;
+
+ [Tooltip("Hunter-hit flash strength: multiplies the authored _XRayColor and turns off its breathing pulse for the flash window, so a hit is a strong solid pop instead of the subtle breathing reveal used elsewhere.")]
+ [Min(1f)]
+ [SerializeField] private float xrayFlashBoost = 3f;
private float _xrayTarget; // desired _XRayColor alpha (0/1); the material eases toward it each frame
+ private bool _xrayFlash; // hit-flash mode: boosted rgb + pulse off, applied on toggle
+ private Color _xrayBaseColor = Color.white; // authored _XRayColor rgb, captured at setup (alpha ignored)
+ private float _xrayBasePulseStrength; // authored _XRayPulseStrength, restored when the flash ends
[Header("Backend")]
[Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")]
@@ -349,6 +366,19 @@ public partial class MeshPainter : MonoBehaviour
private bool _gpuActive;
private bool _setupValid;
+ // Networked remote replicas set this (before Start) to SKIP the base seed during the initial backend
+ // build: the RTs/materials are still created, but the grey/white base pixels are not uploaded. The body
+ // is reveal-gated hidden until the painting snapshot is applied (which re-seeds the backend), so seeding
+ // the base at Start is wasted work that stalls every observer's spawn frame (SetPixels/Apply/coverage-
+ // raster per map). One-shot: consumed at the end of Start, so any later (re)build seeds normally. The
+ // owner never defers (it shows immediately).
+ public bool DeferInitialSeed { get; set; }
+
+ // True once the active backend holds valid content. False only between a DEFERRED setup and the first
+ // re-seed/stamp: during that window the reveal gate hides the body and TryCaptureSnapshot serves the
+ // material seed instead of the still-uninitialized RTs (see EnsureBackendSeeded).
+ private bool _backendSeeded;
+
// Seed colors per map in canonical (material) layout, built once at Start:
// albedo -> RGB color
// MSO -> R = metallic, G = occlusion/height (0.5 = neutral: no relief, AO = 1), A = smoothness
@@ -401,6 +431,7 @@ public partial class MeshPainter : MonoBehaviour
private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness");
private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen");
private static readonly int XRayColorId = Shader.PropertyToID("_XRayColor");
+ private static readonly int XRayPulseStrengthId = Shader.PropertyToID("_XRayPulseStrength");
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask");
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
@@ -446,12 +477,22 @@ public partial class MeshPainter : MonoBehaviour
// Seed the X-ray reveal target from the authored alpha so the fade baseline matches the material
// (authored 0 = off). NetworkPlayerSetup drives this target at runtime; Update eases the alpha to it.
- if (_materialInstance.HasProperty(XRayColorId)) _xrayTarget = _materialInstance.GetColor(XRayColorId).a;
+ // Also cache the authored glow rgb + pulse strength so the hit flash can boost/restore them.
+ if (_materialInstance.HasProperty(XRayColorId))
+ {
+ Color xc = _materialInstance.GetColor(XRayColorId);
+ _xrayBaseColor = new Color(xc.r, xc.g, xc.b, 1f);
+ _xrayTarget = xc.a;
+ }
+ if (_materialInstance.HasProperty(XRayPulseStrengthId))
+ _xrayBasePulseStrength = _materialInstance.GetFloat(XRayPulseStrengthId);
BuildSeedColors();
// Always start fresh from the material seed. Saved paintings are loaded explicitly from the
// HUD library (MeshPainter.Persistence), never auto-loaded on start.
SetupBackend(useGpu, _seedCanonical);
+ _backendSeeded = !DeferInitialSeed;
+ DeferInitialSeed = false; // one-shot: only the initial build defers; later (re)builds seed normally
ConfigureMaterialPbr();
InitUndoHistory(); // baseline = material seed, captured before any stroke
diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs
index b14aa98..8459b43 100644
--- a/Assets/Scripts/Network/NetworkPlayerSetup.cs
+++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
+using Chameleon.Components;
///
/// Per-avatar network glue on the spawned player. The local owner binds the scene singletons
@@ -102,6 +103,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
public void MarkConversionSpawn(Vector3 worldPos) => _conversionSpawnPos = worldPos;
private Renderer[] _bodyRenderers; // skinned body mesh(es), toggled by the per-client visibility pass
+ private FurShellRenderer[] _furRenderers; // fur draws via a custom GPU-instanced pass (NOT a Renderer); gated with the body so the avatar reveals atomically and never shows stale pooled fur
private bool _visibilityApplied;
private bool _lastVisible = true;
private bool _renderGate; // conversion: body force-hidden until the fresh hunter has settled into place
@@ -113,6 +115,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
private PlayerCharacterController _controller; // sibling, cached (the freeze re-asserts per frame)
private bool _xrayApplied;
private bool _lastXRay;
+ private bool _lastFlash;
private bool _frozenApplied;
private bool _lastFrozen;
private float _xrayFlashUntil; // Time.time deadline for the just-shot X-ray flash (local per client)
@@ -122,6 +125,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
{
_active.Add(this); // scoreboard registry (paired with OnNetworkDespawn)
_bodyRenderers = GetComponentsInChildren(true);
+ _furRenderers = GetComponentsInChildren(true);
_painter = GetComponentInChildren(true);
_paintSync = GetComponentInChildren(true);
_concealment = GetComponentInChildren(true);
@@ -297,6 +301,12 @@ public class NetworkPlayerSetup : NetworkBehaviour
if (_bodyRenderers != null)
foreach (var r in _bodyRenderers)
if (r) r.enabled = visible;
+ // Fur is drawn by a custom GPU-instanced pass, NOT a Renderer, so the skinned-mesh toggle above
+ // misses it; gate it too (enabled=false unsubscribes its camera hook), or a deferred/reveal-gated
+ // body would show stale pooled fur strands while its own mesh is hidden.
+ if (_furRenderers != null)
+ foreach (var f in _furRenderers)
+ if (f) f.enabled = visible;
_lastVisible = visible;
_visibilityApplied = true;
}
@@ -353,20 +363,21 @@ public class NetworkPlayerSetup : NetworkBehaviour
private void UpdateXRayReveal()
{
if (!_painter) return;
- bool reveal = ComputeXRay();
- if (_xrayApplied && reveal == _lastXRay) return;
- _painter.XRayRevealed = reveal;
+ // Just-shot flash: a hunter's hit lights this hider up for a brief window (server-broadcast). It drives
+ // a distinct, much stronger flash mode (steady + boosted), so it is tracked apart from the normal reveal.
+ bool flash = Time.time < _xrayFlashUntil;
+ bool reveal = flash || ComputeXRay();
+ if (_xrayApplied && reveal == _lastXRay && flash == _lastFlash) return;
+ _painter.SetXRayReveal(reveal, flash);
_lastXRay = reveal;
+ _lastFlash = flash;
_xrayApplied = true;
}
- // The X-ray reveal predicate for this avatar on the local machine.
+ // The normal (non-flash) X-ray reveal predicate for this avatar on the local machine. The just-shot flash
+ // is OR-combined by the caller so it can select the stronger flash mode.
private bool ComputeXRay()
{
- // Just-shot flash: a hunter's hit lights this hider up for a brief window (server-broadcast), on top
- // of the normal reveal triggers, so the moment of the hit reads before the hider leaves play.
- if (Time.time < _xrayFlashUntil) return true;
-
// Buried-hider reveal: already gated to hider + running match on the owner before it is published.
if (_embeddedReveal.Value) return true;
diff --git a/Assets/Scripts/Network/PlayerPaintSync.cs b/Assets/Scripts/Network/PlayerPaintSync.cs
index 274180b..6504c71 100644
--- a/Assets/Scripts/Network/PlayerPaintSync.cs
+++ b/Assets/Scripts/Network/PlayerPaintSync.cs
@@ -158,6 +158,12 @@ public class PlayerPaintSync : NetworkBehaviour
// Remote replica: never paint from this machine's mouse; paint only from replicated data.
_painter.PaintingEnabled = false;
+ // Skip the wasted base seed on the initial backend build: this reveal-gated body stays hidden
+ // until the painting snapshot is applied (which re-seeds the backend), so seeding grey/white at
+ // Start only stalls this observer's spawn frame. Set BEFORE the painter's Start (OnNetworkSpawn
+ // runs first), so the deferral takes effect.
+ _painter.DeferInitialSeed = true;
+
// Track the owner's emission intensity; seed the current (late-join) value right away.
_emissionIntensity.OnValueChanged += OnEmissionChanged;
_painter.emissionIntensity = _emissionIntensity.Value;