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: