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;