diff --git a/Assets/Scripts/Components/HunterLaserBeam.cs b/Assets/Scripts/Components/HunterLaserBeam.cs
new file mode 100644
index 0000000..d543c07
--- /dev/null
+++ b/Assets/Scripts/Components/HunterLaserBeam.cs
@@ -0,0 +1,135 @@
+using UnityEngine;
+using UnityEngine.Rendering;
+
+///
+/// Momentary world-space laser beam for a hunter's shot. Purely visual (no networking): a LineRenderer drawn
+/// from the muzzle to the hit point that fades IN fast then OUT over its lifetime, animating both opacity
+/// (via per-vertex colour alpha, read by the Chameleon/Laser shader) and thickness (via widthMultiplier).
+/// Self-contained: it builds its own LineRenderer child + an additive material at runtime, so it can be added
+/// to a hunter avatar purely in code with no prefab authoring. Call to trigger; the beam
+/// recycles itself (disables the renderer) once it expires.
+///
+[DisallowMultipleComponent]
+public sealed class HunterLaserBeam : MonoBehaviour
+{
+ [Header("Look")]
+ [ColorUsage(true, true)]
+ [Tooltip("HDR beam tint. Its alpha is ignored - the animated opacity envelope drives visibility.")]
+ [SerializeField] private Color color = new Color(1f, 0.25f, 0.25f, 1f);
+
+ [Tooltip("HDR emission multiplier applied to the tint. >1 pushes the beam past LDR into bloom.")]
+ [Min(0f)]
+ [SerializeField] private float emission = 6f;
+
+ [Tooltip("Peak beam thickness in world units (the width envelope scales this).")]
+ [Min(0f)]
+ [SerializeField] private float maxWidth = 0.06f;
+
+ [Tooltip("Optional material override. Empty -> a built-in additive laser material (Chameleon/Laser) is created at runtime.")]
+ [SerializeField] private Material materialOverride;
+
+ [Header("Timing")]
+ [Tooltip("Total beam lifetime in seconds (fade-in + fade-out).")]
+ [Min(0.01f)]
+ [SerializeField] private float lifetime = 0.5f;
+
+ [Tooltip("Fraction of the lifetime spent fading IN (the rest fades out).")]
+ [Range(0f, 1f)]
+ [SerializeField] private float fadeInFraction = 0.2f;
+
+ [Tooltip("Thickness floor as a fraction of maxWidth at the envelope's ends, so the beam never pinches to a dot.")]
+ [Range(0f, 1f)]
+ [SerializeField] private float minWidthFraction = 0.35f;
+
+ private LineRenderer _line;
+ private Material _material;
+ private bool _ownsMaterial;
+ private float _age;
+ private bool _active;
+
+ private static readonly int ColorId = Shader.PropertyToID("_Color");
+
+ private void Awake()
+ {
+ var go = new GameObject("LaserBeamLine");
+ go.transform.SetParent(transform, false);
+
+ _line = go.AddComponent();
+ _line.useWorldSpace = true; // endpoints are fixed world points; the beam ignores avatar motion
+ _line.positionCount = 2;
+ _line.numCapVertices = 2;
+ _line.numCornerVertices = 0;
+ _line.alignment = LineAlignment.View;
+ _line.textureMode = LineTextureMode.Stretch;
+ _line.shadowCastingMode = ShadowCastingMode.Off;
+ _line.receiveShadows = false;
+ _line.lightProbeUsage = LightProbeUsage.Off;
+ _line.reflectionProbeUsage = ReflectionProbeUsage.Off;
+
+ if (materialOverride)
+ {
+ _material = materialOverride;
+ }
+ else
+ {
+ _material = new Material(Shader.Find("Chameleon/Laser"));
+ _ownsMaterial = true;
+ }
+ _line.material = _material;
+ if (_material && _material.HasProperty(ColorId))
+ {
+ Color hdr = color * emission; // push the tint into HDR so bloom picks it up
+ hdr.a = 1f; // alpha is unused by the additive shader
+ _material.SetColor(ColorId, hdr);
+ }
+
+ _line.enabled = false;
+ }
+
+ /// Fires a one-shot beam along the given world-space segment; restarts the animation if already running.
+ public void Fire(Vector3 start, Vector3 end)
+ {
+ if (!_line) return;
+ _line.SetPosition(0, start);
+ _line.SetPosition(1, end);
+ _age = 0f;
+ _active = true;
+ _line.enabled = true;
+ ApplyEnvelope(0f);
+ }
+
+ private void Update()
+ {
+ if (!_active) return;
+ _age += Time.deltaTime;
+ float t = lifetime > 0f ? _age / lifetime : 1f;
+ if (t >= 1f)
+ {
+ _active = false;
+ _line.enabled = false;
+ return;
+ }
+ ApplyEnvelope(t);
+ }
+
+ // Maps normalized life t (0..1) to opacity + thickness: rises over fadeInFraction, then eases back down.
+ private void ApplyEnvelope(float t)
+ {
+ float inFrac = Mathf.Clamp01(fadeInFraction);
+ float e = t < inFrac
+ ? (inFrac > 0f ? t / inFrac : 1f)
+ : (inFrac < 1f ? 1f - (t - inFrac) / (1f - inFrac) : 1f);
+ e = Mathf.Clamp01(e);
+ e = e * e * (3f - 2f * e); // smoothstep for a softer ramp
+
+ _line.widthMultiplier = maxWidth * Mathf.Lerp(minWidthFraction, 1f, e);
+ var vc = new Color(1f, 1f, 1f, e); // hue from the material _Color; alpha carries the fade envelope
+ _line.startColor = vc;
+ _line.endColor = vc;
+ }
+
+ private void OnDestroy()
+ {
+ if (_ownsMaterial && _material) Destroy(_material);
+ }
+}
diff --git a/Assets/Scripts/Components/HunterLaserBeam.cs.meta b/Assets/Scripts/Components/HunterLaserBeam.cs.meta
new file mode 100644
index 0000000..c25c2f5
--- /dev/null
+++ b/Assets/Scripts/Components/HunterLaserBeam.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3755898ae12d28e4585f6e55547c768d
\ No newline at end of file
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
index da5cd9a..7ee7166 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs
@@ -32,11 +32,27 @@ public partial class MeshPainter
}
///
- /// Tears down the active backend and rebuilds it from externally supplied canonical maps (load /
- /// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for
- /// the new content. The input arrays are never mutated (SetupBackend clones its seed).
+ /// Re-seeds the backend from externally supplied canonical maps (load / late-join snapshot / undo),
+ /// rebuilding the island/dilation edge padding for the new content. The input arrays are never mutated
+ /// (both seed paths clone before touching a map).
+ ///
+ /// Fast path: when the backend is already built on this same type + size - the common runtime reseed
+ /// (remote snapshot apply, carry/library load, undo/redo) - the existing RTs/textures are re-filled in
+ /// place instead of a full teardown+rebuild. At 1024² a rebuild frees and re-creates nine ARGBHalf RTs
+ /// (~70 MB of GPU allocation) every call, which stalls the frame and shows up as a hitch on every
+ /// observer when a (freshly painted) avatar spawns. Re-seeding reuses the RTs (identical size/format)
+ /// for a bit-identical result. A pending GPU<->CPU switch or an un-built backend falls through to the
+ /// full rebuild.
///
- private void ReseedCanonical(Color32[][] canonical) => RebuildBackend(useGpu, canonical);
+ private void ReseedCanonical(Color32[][] canonical)
+ {
+ if (_setupValid)
+ {
+ if (useGpu && _gpuActive && _gpu != null) { ReseedGpuInPlace(canonical); _hasLastScreen = false; return; }
+ if (!useGpu && !_gpuActive && _cpu != null) { ReseedCpuInPlace(canonical); _hasLastScreen = false; return; }
+ }
+ RebuildBackend(useGpu, canonical);
+ }
/// 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 f63899d..1ab5c2d 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
@@ -43,6 +43,27 @@ public partial class MeshPainter
return px;
}
+ ///
+ /// Re-seeds the ALREADY-built CPU maps from canonical data without recreating the Texture2D mirrors:
+ /// each map's pixel buffer + island guard are re-seeded and re-uploaded into the existing texture,
+ /// mirroring SetupCpu but reusing the textures. The GPU counterpart is ReseedGpuInPlace. Callers ensure
+ /// !_gpuActive && _cpu != null.
+ ///
+ private void ReseedCpuInPlace(Color32[][] canonical)
+ {
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ var cm = _cpu[mi];
+ cm.pixels = SeedCpuPixels(mi, canonical);
+ cm.painted = (bool[])GetIslandMask().Clone();
+ if (cm.fillStamp != null) System.Array.Clear(cm.fillStamp, 0, cm.fillStamp.Length);
+ cm.dilateStamp = 0;
+ cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1; // no pending dirty region
+ cm.tex.SetPixels32(cm.pixels);
+ cm.tex.Apply(false);
+ }
+ }
+
private void TeardownCpu()
{
if (_cpu == null) return;
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
index 7b1ff18..fac0d68 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs
@@ -100,6 +100,23 @@ public partial class MeshPainter
}
}
+ ///
+ /// Re-seeds the ALREADY-built GPU maps from canonical data without recreating the RenderTextures or
+ /// materials: each map's display RT is re-uploaded + re-stamped (MSO re-packed) exactly as SetupGpu
+ /// does, but the existing RTs (same size/format) are reused, so the fur/albedo RTs a sibling
+ /// FurShellRenderer samples keep their references. Avoids the per-apply ~70 MB RT alloc/free churn a
+ /// full RebuildBackend incurs at 1024². Callers ensure _gpuActive && _gpu != null.
+ ///
+ private void ReseedGpuInPlace(Color32[][] canonical)
+ {
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ SeedGpuMap(mi, canonical);
+ var gm = _gpu[mi];
+ AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
+ }
+ }
+
///
/// Stamps the dilation coverage guard into a map's alpha: rasterizes the mesh with the brush
/// shader's coverage pass (pass 1), writing alpha = 1 on exactly the texels the GPU brush can
diff --git a/Assets/Scripts/GameMode/FirstPersonMode.cs b/Assets/Scripts/GameMode/FirstPersonMode.cs
index a017196..982a559 100644
--- a/Assets/Scripts/GameMode/FirstPersonMode.cs
+++ b/Assets/Scripts/GameMode/FirstPersonMode.cs
@@ -78,36 +78,58 @@ public class FirstPersonMode : GameMode
return _setup && _setup.ScopedModes && _setup.Role == PlayerRole.Hunter;
}
+ // Beam starts a touch ahead of the eye so the FP near plane doesn't clip it and it reads as coming from
+ // the player rather than the exact camera pivot.
+ private const float MuzzleForwardOffset = 0.2f;
+
+ // Lateral muzzle offset (world right of the eye) so the beam originates off-centre instead of dead ahead
+ // - a dead-centre beam looks bad in first person. Purely visual; the shot raycast stays centred (aim
+ // unchanged), only the beam's drawn origin shifts, and the world-space endpoints replicate to peers.
+ private const float MuzzleRightOffset = 0.15f;
+
private void TryShoot()
{
var match = MatchManager.Instance;
Transform cam = Manager.FirstPerson.transform; // FP vcam sits at the eye, facing the look direction
float range = Mathf.Max(0f, match.ShootRange);
+ Vector3 origin = cam.position;
+ Vector3 dir = cam.forward;
- int count = Physics.RaycastNonAlloc(cam.position, cam.forward, _hits, range, ~0, QueryTriggerInteraction.Ignore);
+ int count = Physics.RaycastNonAlloc(origin, dir, _hits, range, ~0, QueryTriggerInteraction.Ignore);
if (count == _hits.Length && !_hitsSaturated)
{
_hitsSaturated = true;
Debug.LogWarning($"FirstPersonMode: shot raycast buffer saturated ({count}); the nearest blocker may be missed.");
}
- if (count == 0) return;
- // Nearest-first so the shot stops at the first solid thing it meets (a wall blocks; a nearer avatar
- // shields a farther one). The hunter's own body is skipped so looking down doesn't self-block.
- System.Array.Sort(_hits, 0, count, RaycastDistanceComparer.Instance);
-
- NetworkObject self = Manager.Player ? Manager.Player.GetComponent() : null;
- for (int i = 0; i < count; i++)
+ Vector3 end = origin + dir * range; // nothing hit -> the beam runs to max range
+ if (count > 0)
{
- var netObj = _hits[i].collider.GetComponentInParent();
- if (netObj && self && netObj == self) continue;
- if (!netObj || !netObj.IsPlayerObject) return; // first solid hit is world geometry -> blocked
+ // Nearest-first so the shot stops at the first solid thing it meets (a wall blocks; a nearer avatar
+ // shields a farther one). The hunter's own body is skipped so looking down doesn't self-block.
+ System.Array.Sort(_hits, 0, count, RaycastDistanceComparer.Instance);
- var setup = netObj.GetComponent();
- if (setup && setup.Role == PlayerRole.Hider && !setup.IsFound)
- match.ReportHit(netObj.OwnerClientId);
- return; // the first non-self hit decides the shot
+ NetworkObject self = Manager.Player ? Manager.Player.GetComponent() : null;
+ for (int i = 0; i < count; i++)
+ {
+ var netObj = _hits[i].collider.GetComponentInParent();
+ if (netObj && self && netObj == self) continue; // skip own body
+
+ end = _hits[i].point; // first non-self solid hit stops the beam here (wall or avatar)
+ if (netObj && netObj.IsPlayerObject)
+ {
+ var setup = netObj.GetComponent();
+ if (setup && setup.Role == PlayerRole.Hider && !setup.IsFound)
+ match.ReportHit(netObj.OwnerClientId);
+ }
+ break; // the first non-self hit decides the shot
+ }
}
+
+ // Fire the beam on every shot (hit or miss), drawn locally and replicated to the other clients.
+ // The muzzle sits slightly forward + right of the eye so the beam is off-centre (see MuzzleRightOffset).
+ Vector3 muzzle = origin + dir * MuzzleForwardOffset + cam.right * MuzzleRightOffset;
+ if (_setup) _setup.FireShot(muzzle, end);
}
private sealed class RaycastDistanceComparer : System.Collections.Generic.IComparer
diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs
index e9fd7bb..5a961f0 100644
--- a/Assets/Scripts/Network/MatchManager.cs
+++ b/Assets/Scripts/Network/MatchManager.cs
@@ -115,6 +115,12 @@ public class MatchManager : MonoBehaviour
[Min(0f)]
[SerializeField] private float shootCooldown = 0.4f;
+ [Tooltip("Seconds a shot hider stays lit with the X-ray glow before it is actually removed from play " +
+ "(Classic: hidden as found; Conversion: respawned as a hunter). Gives the just-shot flash time to " +
+ "be seen. The hider stays a live target during this window; 0 = remove instantly (no visible flash).")]
+ [Min(0f)]
+ [SerializeField] private float hitRevealDelay = 0.35f;
+
[Header("Concealment reveal")]
[Tooltip("A hider whose ConcealmentMeter EmbeddedPercent reaches this value (while a match is running) is " +
"revealed through walls with the material's X-ray glow, so nobody can hide by burying itself deep " +
@@ -192,6 +198,9 @@ public class MatchManager : MonoBehaviour
// empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended
// early via the authority's Back-to-Lobby).
private readonly HashSet _pendingHiders = new HashSet();
+ // Server-only. Hiders that were hit and are in their X-ray flash window, not yet removed. Kept out of the
+ // seek-end check by staying in _pendingHiders; this set only dedupes a second shot during the flash.
+ private readonly HashSet _revealingHiders = new HashSet();
// Server-only. Live NGO clientId -> persistent per-install id, reported by each client on connect. The
// clientId is ephemeral (reused after a disconnect); the value is stable across that client's restarts.
private readonly Dictionary _persistentIds = new Dictionary();
@@ -398,6 +407,7 @@ public class MatchManager : MonoBehaviour
// A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger.
_pendingHiders.Remove(clientId);
+ _revealingHiders.Remove(clientId); // and out of the in-flight flash set (its resolve coroutine no-ops)
// Free the ephemeral clientId -> identity mapping (the clientId will be reused by a later joiner).
_persistentIds.Remove(clientId);
@@ -517,6 +527,7 @@ public class MatchManager : MonoBehaviour
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
_pendingHiders.Clear();
+ _revealingHiders.Clear();
foreach (ulong id in _net.ConnectedClientsIds)
if (!hunterIds.Contains(id)) _pendingHiders.Add(id);
@@ -656,13 +667,40 @@ public class MatchManager : MonoBehaviour
MarkHiderFound(targetClientId);
}
- // Server-only. Resolves a hit on a hider for the active mode, and drops it from the pending set; the seek
- // loop ends the phase early once that empties (no hiders left to find/convert). Classic flags the avatar
- // found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter.
+ // Server-only. Resolves a hit on a hider: flashes its X-ray glow on every client, then (after hitRevealDelay
+ // so the flash is actually seen) drops it from the pending set and applies the mode outcome - Classic flags
+ // the avatar found (replicated by its NetworkPlayerSetup -> free-cam); Conversion respawns it as a Hunter.
+ // The hider stays in _pendingHiders during the flash so seek doesn't end early on the last hit; the seek
+ // loop ends the phase once _pendingHiders empties (no hiders left to find/convert).
private void MarkHiderFound(ulong hiderClientId)
{
- _pendingHiders.Remove(hiderClientId);
+ if (_revealingHiders.Contains(hiderClientId)) return; // already flashing / being resolved
+
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
+ {
+ var setup = c.PlayerObject.GetComponent();
+ if (setup) setup.FlashHitReveal(hitRevealDelay); // light it up on every client, now
+ }
+
+ _revealingHiders.Add(hiderClientId);
+ StartCoroutine(ResolveHitAfterReveal(hiderClientId));
+ }
+
+ // Server-only. Waits out the reveal flash, then removes the hider and applies the mode outcome. Split from
+ // MarkHiderFound so the glow is visible before the avatar leaves play (Classic hide / Conversion despawn).
+ private IEnumerator ResolveHitAfterReveal(ulong hiderClientId)
+ {
+ if (hitRevealDelay > 0f) yield return new WaitForSeconds(hitRevealDelay);
+
+ _revealingHiders.Remove(hiderClientId);
+ if (_net == null || !_net.IsServer) yield break;
+
+ _pendingHiders.Remove(hiderClientId);
+
+ // A Back-to-Lobby (EndMatch) during the flash tears the match down and respawns everyone; don't then
+ // convert/found a stale avatar.
+ if (Phase == MatchPhase.InMatch &&
+ _net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
{
if (matchMode == MatchMode.Conversion)
ConvertToHunter(hiderClientId, c.PlayerObject);
@@ -672,6 +710,7 @@ public class MatchManager : MonoBehaviour
if (setup) setup.ServerMarkFound();
}
}
+
string verb = matchMode == MatchMode.Conversion ? "converted to hunter" : "found";
Debug.Log($"[MatchManager] Hider {hiderClientId} {verb} - {_pendingHiders.Count} hider(s) left.", this);
}
diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs
index 3047105..b14aa98 100644
--- a/Assets/Scripts/Network/NetworkPlayerSetup.cs
+++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs
@@ -115,6 +115,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
private bool _lastXRay;
private bool _frozenApplied;
private bool _lastFrozen;
+ private float _xrayFlashUntil; // Time.time deadline for the just-shot X-ray flash (local per client)
+ private HunterLaserBeam _laser; // shot beam visual, lazily attached on the hunter avatar
public override void OnNetworkSpawn()
{
@@ -361,6 +363,10 @@ public class NetworkPlayerSetup : NetworkBehaviour
// The X-ray reveal predicate for this avatar on the local machine.
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;
@@ -369,6 +375,52 @@ public class NetworkPlayerSetup : NetworkBehaviour
return ComputeResolutionFreeze();
}
+ // --- Hunter shot beam ------------------------------------------------------------------------------
+ // Cosmetic and client-trusted (a co-op hide & seek, matching the ReportHit trust model). The local
+ // shooter draws its own beam instantly for responsiveness and relays the world-space segment to the
+ // server, which echoes it to every other client. The RPCs live on the hunter avatar (this
+ // NetworkObject), so they only run on this shooter's instances - other avatars never draw a beam.
+
+ /// Owner-only. Draws the shot beam locally now and replicates the world-space segment so every
+ /// other client sees the same shot.
+ public void FireShot(Vector3 start, Vector3 end)
+ {
+ if (!IsOwner) return;
+ EnsureLaser().Fire(start, end);
+ FireShotServerRpc(start, end);
+ }
+
+ [ServerRpc]
+ private void FireShotServerRpc(Vector3 start, Vector3 end) => FireShotClientRpc(start, end);
+
+ [ClientRpc]
+ private void FireShotClientRpc(Vector3 start, Vector3 end)
+ {
+ if (IsOwner) return; // the shooter already drew it locally
+ EnsureLaser().Fire(start, end);
+ }
+
+ private HunterLaserBeam EnsureLaser()
+ {
+ if (!_laser) _laser = GetComponentInChildren(true);
+ if (!_laser) _laser = gameObject.AddComponent();
+ return _laser;
+ }
+
+ // --- Hunter hit flash ------------------------------------------------------------------------------
+
+ /// Server-only. Flashes THIS hider's through-walls X-ray glow on every client for
+ /// (the just-shot feedback), overriding the normal reveal predicate for that
+ /// window. MatchManager delays the actual removal by the same span so the glow is seen before it leaves play.
+ public void FlashHitReveal(float seconds)
+ {
+ if (!IsServer) return;
+ FlashHitRevealClientRpc(Mathf.Max(0f, seconds));
+ }
+
+ [ClientRpc]
+ private void FlashHitRevealClientRpc(float seconds) => _xrayFlashUntil = Time.time + seconds;
+
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
// Shared with MatchHud. Called per frame per avatar during hide/seek, so the GetComponent result is
// cached per local PlayerObject; a respawn/conversion swaps the object and refreshes the cache.
diff --git a/Assets/Shaders/Laser.shader b/Assets/Shaders/Laser.shader
new file mode 100644
index 0000000..32549ef
--- /dev/null
+++ b/Assets/Shaders/Laser.shader
@@ -0,0 +1,67 @@
+Shader "Chameleon/Laser"
+{
+ // Unlit additive beam for the hunter's shot LineRenderer. The animated fade (opacity) is carried by the
+ // per-vertex colour alpha the HunterLaserBeam writes each frame; _Color is the constant HDR tint.
+ Properties
+ {
+ [HDR] _Color ("Color", Color) = (1, 0.25, 0.18, 1)
+ _Softness ("Edge Softness", Range(0, 1)) = 1
+ }
+
+ SubShader
+ {
+ Tags { "RenderType" = "Transparent" "Queue" = "Transparent" "RenderPipeline" = "UniversalPipeline" "IgnoreProjector" = "True" }
+
+ Pass
+ {
+ Name "LaserAdditive"
+ Blend One One // pure additive glow (vertex-alpha folded into rgb below)
+ ZWrite Off
+ Cull Off
+ ZTest LEqual // the beam is occluded by nearer opaque geometry
+
+ HLSLPROGRAM
+ #pragma vertex vert
+ #pragma fragment frag
+ #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
+
+ struct Attributes
+ {
+ float4 positionOS : POSITION;
+ float4 color : COLOR;
+ float2 uv : TEXCOORD0;
+ };
+
+ struct Varyings
+ {
+ float4 positionHCS : SV_POSITION;
+ float4 color : COLOR;
+ float2 uv : TEXCOORD0;
+ };
+
+ half4 _Color;
+ half _Softness;
+
+ Varyings vert (Attributes IN)
+ {
+ Varyings OUT;
+ OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
+ OUT.color = IN.color;
+ OUT.uv = IN.uv;
+ return OUT;
+ }
+
+ half4 frag (Varyings IN) : SV_Target
+ {
+ // LineRenderer maps UV.y across the strip width (0..1): bright core, soft edges.
+ half across = abs(IN.uv.y * 2.0h - 1.0h);
+ half edge = lerp(1.0h, saturate(1.0h - across), saturate(_Softness));
+ half intensity = IN.color.a * edge; // vertex alpha = animated fade envelope
+ half3 rgb = _Color.rgb * IN.color.rgb * intensity;
+ return half4(rgb, 1.0h); // additive (Blend One One) -> alpha unused
+ }
+ ENDHLSL
+ }
+ }
+ Fallback Off
+}
diff --git a/Assets/Shaders/Laser.shader.meta b/Assets/Shaders/Laser.shader.meta
new file mode 100644
index 0000000..243626f
--- /dev/null
+++ b/Assets/Shaders/Laser.shader.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: 8e868169b3164c042b31a3312f865345
+ShaderImporter:
+ externalObjects: {}
+ defaultTextures: []
+ nonModifiableTextures: []
+ userData:
+ assetBundleName:
+ assetBundleVariant: