perf(paint): stronger X-ray hit flash + kill spawn/respawn freeze

X-ray hit flash: drive a dedicated flash mode instead of the normal
reveal - pulse off (steady), intensity boost (xrayFlashBoost, default 3x),
snap-on, then restore authored color/pulse exactly. Fixes the weak/dark
flicker where the 0.35s hit window often caught only the pulse's near-off
plateau.

Spawn/respawn freeze: pool GPU RenderTextures across despawn->respawn
(TeardownGpu recycles, CreateRT pops) so a hider->hunter conversion no
longer churns ~70MB of RT alloc/free on every observer; reuse staging
textures to cut per-map GC. Defer the blank base-seed on reveal-gated
remote replicas (DeferInitialSeed) - the real pixels arrive with the
snapshot apply anyway; gate the FurShellRenderer alongside the body so a
deferred replica never shows the pooled RT's stale fur; EnsureBackendSeeded
+ TryCaptureSnapshot guard against capture-before-reseed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-04 15:14:20 +02:00
1 parent 4ceaf85c8f
commit 48975c0f20
9 files changed
+196 -24

No files matched your search

@@ -46,6 +46,7 @@ public partial class MeshPainter
/// </summary>
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);
}
/// <summary>
/// Materializes a DEFERRED backend's base seed on demand: a remote replica that skipped its Start seed
/// (see <see cref="DeferInitialSeed"/>) 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.
/// </summary>
private void EnsureBackendSeeded()
{
if (!_backendSeeded) ReseedCanonical(null);
}
/// <summary>Tears down the active backend and sets up the requested one on the given maps.</summary>
private void RebuildBackend(bool gpu, Color32[][] canonical)
{
@@ -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);
}
@@ -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<RenderTexture> s_rtPool =
new System.Collections.Generic.Stack<RenderTexture>();
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;
/// <summary>
/// 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.</summary>
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);
}
/// <summary>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.</summary>
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;
}
/// <summary>
@@ -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;
@@ -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++)
{
@@ -293,4 +293,22 @@ public partial class MeshPainter
c.a = a;
_materialInstance.SetColor(XRayColorId, c);
}
/// <summary>Applies the current hit-flash state to the X-ray material: flash mode boosts the authored glow
/// rgb by <c>xrayFlashBoost</c>, 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.</summary>
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);
}
}
@@ -126,13 +126,30 @@ public partial class MeshPainter : MonoBehaviour
public bool XRayRevealed
{
get => _xrayTarget > 0f;
set => _xrayTarget = value ? 1f : 0f;
set => SetXRayReveal(value, false);
}
/// <summary>Drives the X-ray reveal with an optional hunter-hit <paramref name="flash"/> mode: the flash
/// multiplies the authored glow by <see cref="xrayFlashBoost"/> 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.</summary>
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