ZOA Crosshair
Reticles painted from code: an archetype builds strokes, the view stamps them, the definition decides which archetype runs.
A reticle is a poor fit for a texture. It has to bloom with spread, kick with recoil, tighten under aim, shrink as magnification climbs, and take its colour from whatever theme the player picked, and every one of those is a per-frame parameter. So the crosshair here is procedural: an archetype implementation receives the frame's state and appends drawing strokes, and the view rasterises them through Painter2D onto a UI Toolkit overlay.
CrosshairDefinition ties it together: a ScriptableObject carrying two reticles. Hipfire paints while the player moves; Ads is the optional variant shown while aiming. When Ads is null the definition still has to say what happens, and there are two answers: hide the procedural reticle entirely, the scoped-weapon path where the optic owns the view, or keep painting hipfire with its spread tightened by a configurable factor.
The surface is built for extension. IProceduralCrosshair is a plain interface, so an author writing an SDF beam reticle or a curved arc need not inherit the built-in geometry stack. Implementations are serializable POCOs held in the definition's SerializeReference fields, so their authoring parameters show up in the Inspector without a second asset.
Depends on (3)
Depended on by (1)
How it works
Concepts
Each archetype encodes a readability decision
CrosshairClass names the canonical archetype categories, and each shipped implementation encodes a readability decision. AssaultRifleCrosshair paints four inward chevrons converging on a bright core, blooming outward as spread climbs. SmgCrosshair is quieter and tighter, four short cardinal ticks close in, so a fast close-range weapon reads as compact. ShotgunCrosshair is a hollow ring that shows the pellet cone widening, with four ticks just outside it. SniperCrosshair is a fine core, tiny inner ticks, outer pips and a faint mil-ring, minimal because aiming is where it does its real work.
EnergyCrosshair and PlasmaCrosshair break the theme tint. Each carries its own weapon-identity colour, cyan for the energy focus ring and violet for the plasma containment diamond, while their ticks stay on the theme accent, so a beam weapon reads as energetic regardless of the HUD palette without falling out of the design language.
The class also drives fallback. When a weapon does not point at a specific definition, it gets the class-default reticle for its CrosshairClass, so an unflagged rifle still shows a rifle reticle. Custom exists for weapon families the built-ins do not cover, and is meant to be paired with a custom IProceduralCrosshair implementation.
The painting model: normalised strokes, no allocations
Build receives a CrosshairState by in-reference and an ICrosshairStrokeBuilder to append to. Coordinates are normalised to the reticle's bounding box, zero to one on both axes with the centre at 0.5, 0.5, and the view scales that to its configured pixel size at paint time. An archetype therefore never measures the screen, never reads a resolution, and never touches a VisualElement.
CrosshairStroke is a flat readonly struct with four kinds, line, dot, rect and circle, and factory methods for each. WithColor overrides the theme accent for a single stroke, so an energy ring can carry its own tint while the rest of the reticle stays themed. A transparent colour means paint with the state's accent.
CrosshairStrokeList is the builder the view owns: one reusable list, cleared and refilled each repaint, so Build calls allocate nothing on a per-frame path. Implementations are expected to honour that and append rather than build their own buffers.
Since the stroke builder has no native glow or blur, ReticleInk supplies the layered approximation every built-in archetype uses: a wider low-alpha underlay beneath a thin bright stroke, plus a brightened hot core. GlowLine, GlowCircle, GlowDot and GlowChevron keep that consistent across the archetypes without pushing it into the interface.
Three zoom modes, one validator
ZoomMode.None is most weapons: ADS may tighten the reticle but the zoom value stays at one. Analog is a variable-power scope, a continuous value clamped between MinZoom and MaxZoom that the input layer drives through SetZoom. Stepped is a discrete optic, a list of levels the input layer walks with CycleZoomStep, which wraps around and is a no-op in the other two modes.
ClampZoom on the definition is the single validator: it returns one for None, clamps into the range for Analog, and snaps to the nearest entry in StepLevels for Stepped. Every service setter routes through it, so an out-of-range value from a scroll wheel or a save file cannot produce an illegal zoom.
Archetypes see the result as CrosshairState.Zoom and the built-ins all use it the same way, contracting geometry by its inverse so the aim point gets finer as magnification climbs. ScopeFovDriver consumes the same value from the other side, narrowing the camera FOV to base divided by zoom, but only when the mode is not None, the service reports ADS active, and the resolved zoom is above one.
Identity changes fire events, cosmetic changes do not
ICrosshairService.Changed fires only when the active definition, the zoom value or the ADS flag changes. Spread and recoil push through their own setters that update fields without raising the event, because they change every frame and an event per frame would be pure noise.
The view's repaint loop reads those fields directly and bakes them into the per-frame CrosshairState alongside the theme-resolved accent colour. RecoilOffset arrives in screen pixels, already projected by the weapon controller from the camera's angular kick through its focal length, so a recoil gain of one makes the reticle track the real ballistics rather than an invented multiplier.
Swapping definitions resets zoom to the new definition's clamped DefaultZoom. A player going from a scoped rifle to a pistol does not carry the rifle's four-times magnification across the swap.
One service per rig
CrosshairService is a plain POCO with no MonoBehaviour and no scene state, and CrosshairView constructs one in Awake and exposes it through its Service property. Consumers on the player rig resolve the nearest view and drive that instance instead of fetching a global registration, so two split-screen rigs each carry an independent reticle, zoom state and ADS flag.
ScopeFovDriver and ScopeFrameOverlay follow the same rule: they read the sibling view's service rather than a global, and each is a separate MonoBehaviour so a project that wants scope FOV without a procedural reticle can drop just the driver.
The view owns its own UIDocument per the per-widget HUD pattern, pins its sorting order to the crosshair tier, and claims the full-screen HUD slot through the shared registry so it coexists with the map and other full-screen overlays instead of fighting them. ScopeFrameOverlay sits five above the crosshair tier so its eyepiece mask paints over the procedural reticle when both are present.
ADS resolution in one place
ResolveActiveReticle is where the hipfire-versus-ADS decision is made, and the view calls it once per repaint. Not aiming returns the hipfire reticle. Aiming with an Ads reticle authored returns that. Aiming with no Ads reticle and HideOnAds set returns null, which means paint nothing and let the scope own the view. Aiming with no Ads reticle and HideOnAds clear returns hipfire with the effective spread scaled by one minus AdsTightenFactor.
With no definition active at all the service returns a built-in assault-rifle reticle as a fallback, so an unconfigured rig shows a usable reticle rather than an empty screen.
ScopeFrameOverlay keys off exactly the same condition. It renders only while ADS is active and the active definition has HideOnAds set, which means the eyepiece vignette and mil-dot ladder appear precisely when the procedural reticle has stepped aside, and a non-scoped weapon never gets scope chrome no matter what the player holds.
In the editor
Screens
Screenshot pending
/screenshots/archetype-gallery.png
A grid of the six shipped reticles rendered at the same pixel size and accent colour, labelled, so the readability difference between the rifle chevrons, the SMG cluster, the shotgun ring, the sniper mil-ring, and the two energy variants is directly comparable.
Screenshot pending
/screenshots/spread-bloom.png
The same reticle captured at low, mid and full spread, showing chevrons or the shotgun ring travelling outward as the fire cone widens.
Screenshot pending
/screenshots/scope-frame-overlay.png
A sniper aimed down its optic: the eyepiece vignette blacking out the corners, the mil-dot ladder inside the circular cutout, and no procedural reticle anywhere.
Screenshot pending
/screenshots/crosshair-definition-inspector.png
The definition asset with the Reticles section expanded, showing the SerializeReference hipfire archetype with its authoring parameters inline and the Zoom section configured for a stepped optic.
Setup
Workflow
- 01
Author a definition per weapon or per class
Create a CrosshairDefinition, set its Class, and assign a hipfire reticle. The reticle fields are SerializeReference, so picking an archetype exposes its authoring parameters inline: chevron span, ring radius, glow alpha, and so on.
- 02
Decide what aiming does
Assign an ADS reticle for a weapon that keeps a procedural sight while aiming. For a scoped weapon leave it null and set HideOnAds so the optic owns the view. For everything else leave both and tune AdsTightenFactor, where zero is no tightening and one collapses to a pinpoint.
- 03
Configure zoom
Leave Mode at None for iron sights. Use Analog with MinZoom and MaxZoom for a variable-power scope, or Stepped with StepLevels such as 1, 2, 4, 8 for a discrete optic. DefaultZoom is applied at equip and is snapped to the nearest step in Stepped mode.
- 04
Put the view on the rig
Add a CrosshairView to the local player rig. It requires a UIDocument sibling, configures the panel and sorting order itself, and claims the full-screen HUD slot. Add ScopeFovDriver for magnification and ScopeFrameOverlay if any weapon uses the scope-owns-view path.
- 05
Drive the service from the weapon controller
On equip call SetActive with the weapon's definition. Push ADS state on aim, spread as the fire cone changes, and the projected recoil offset as the camera kicks. Route the scope-cycle input to CycleZoomStep or the analog zoom axis to SetZoom.
Surface
Key types
ICrosshairService
interface
The per-rig contract a weapon controller drives. Identity setters raise Changed; the cosmetic setters do not.
- CrosshairDefinition Active { get; } float Zoom { get; } bool IsAdsActive { get; }
- event Action Changed
- void SetActive(CrosshairDefinition definition)
- void SetZoom(float zoom) void CycleZoomStep(int direction)
- void SetAdsActive(bool active)
- void SetSpread(float spread) void SetRecoilOffset(Vector2 offset)
CrosshairService
service
The default implementation. A plain POCO with no scene state, owned by the view and resolved per rig rather than registered globally.
- float Spread { get; } Vector2 RecoilOffset { get; }
- IProceduralCrosshair ResolveActiveReticle(out float effectiveSpread)
IProceduralCrosshair
interface
The extensibility seam: one method that paints this frame's reticle into the supplied builder. An interface rather than a base class, so a radically different reticle need not inherit the built-in geometry stack.
- void Build(in CrosshairState state, ICrosshairStrokeBuilder builder)
ICrosshairStrokeBuilder
interface
Allocation-free stroke receiver handed to Build. Implementations call the typed helpers rather than constructing strokes and never allocate their own buffers.
- void Add(in CrosshairStroke stroke)
- void Line(Vector2 from, Vector2 to, float thickness)
- void Dot(Vector2 centre, float radius)
- void Rect(Vector2 bottomLeft, Vector2 topRight)
- void Circle(Vector2 centre, float radius, float thickness)
CrosshairState
struct
Everything an archetype needs for one frame, passed by in-reference so the view can stack-allocate it. Spread is clamped to zero-to-one on construction.
- float Zoom bool IsAdsActive float Spread
- Vector2 RecoilOffset (screen pixels, already projected from camera kick)
- Color AccentColor (theme-resolved, plus any hit-flash override)
CrosshairStroke
struct
One drawable stroke in normalised reticle space. Built through the static factories; WithColor overrides the state accent for that stroke alone.
- static CrosshairStroke Line(Vector2 from, Vector2 to, float thickness)
- static CrosshairStroke Dot(Vector2 centre, float radius)
- static CrosshairStroke Rect(Vector2 bottomLeft, Vector2 topRight)
- static CrosshairStroke Circle(Vector2 centre, float radius, float thickness)
- CrosshairStroke WithColor(Color color)
- enum CrosshairStrokeKind { Line, Dot, Rect, Circle }
CrosshairClass
enum
The archetype categories a definition declares. Used as the registry's class-default key when a weapon does not name a specific definition.
- Custom, AssaultRifle, Smg, Pistol
- Shotgun, Sniper, Energy, Plasma
ZoomMode
enum
How zoom is presented. None fixes the value at one, Analog is continuous between the definition's bounds, Stepped walks discrete levels.
- None, Analog, Stepped
CrosshairView
component
The UI Toolkit driver. Owns its UIDocument and the service instance, claims the full-screen HUD slot, resolves the theme accent, and rasterises the archetype's strokes through Painter2D. Added under ZOA/Crosshair/Crosshair View.
- ICrosshairService Service { get; }
- serialized: reticlePixelSize (default 80), recoilGain, recoilLerpSpeed
- requires a sibling UIDocument; execution order -7900
ScopeFovDriver
component
Narrows the camera field of view from the sibling view's zoom and ADS state, lerping symmetrically so entering and leaving ADS both read as a focused glance rather than a snap. Added under ZOA/Crosshair/Scope FOV Driver.
- narrows only when Mode is not None, ADS is active, and zoom is above 1
- target FOV = base FOV divided by zoom
ScopeFrameOverlay
component
Paints the eyepiece vignette and mil-dot ladder while a scope-owns-view definition is aimed. Without it the player gets a magnified but empty viewport. Added under ZOA/Crosshair/Scope Frame Overlay.
- renders only when ADS is active and the active definition sets HideOnAds
- sorts at the crosshair tier plus 5, above the procedural overlay
AssaultRifleCrosshair
class
Four inward chevrons converging on a bright two-tone core with a soft glow underlay. Chevrons travel outward as spread climbs; geometry contracts inversely with zoom.
- serialized: innerGap, chevronLength, chevronSpan, lineThickness
- serialized: dotRadius, spreadBloom, glowAlpha, glowWidth
ShotgunCrosshair
class
A hollow ring sized to the pellet-spread cone with four short outward ticks and a small core. The ring grows with spread, so the reticle shows the cone widening.
- serialized: ringRadius, ringThickness, spreadBloom
- serialized: tickLength, tickThickness, dotRadius, glowAlpha, glowWidth
SniperCrosshair
class
Minimal hipfire reticle: fine core, small inner ticks flanking a gap, outer pips, and a faint mil-ring. Normally paired with HideOnAds so the optic takes over when the player aims.
- serialized: ringRadius, ringThickness, and the tick and pip geometry
SmgCrosshair
class
Tight fast-reading cluster: a small bright core with four short cardinal ticks close in, blooming outward with spread, and quieter than the rifle chevrons.
- serialized: innerGap, tickLength, and the stroke and glow parameters
EnergyCrosshair
class
Glowing focus ring with four emitter ticks and a bright core. Carries its own cyan energy tint for the ring and core as weapon identity while the ticks stay on the theme accent.
- serialized: energyTint (default cyan), ringRadius, and the tick geometry
PlasmaCrosshair
class
A diamond containment aperture around a hot core with four cardinal ticks, blooming with spread. Its violet tint reads distinctly from the cyan energy reticle.
- serialized: plasmaTint (default violet), apertureRadius, and the tick geometry
Surface
Authoring assets
CrosshairDefinition
asset
The per-weapon authoring asset. Holds the hipfire reticle, an optional ADS reticle, the ADS fallback policy, and the zoom configuration. Created via ZOA/Crosshair/Crosshair Definition.
- CrosshairClass Class { get; }
- IProceduralCrosshair Hipfire { get; } IProceduralCrosshair Ads { get; }
- bool HideOnAds { get; } float AdsTightenFactor { get; }
- ZoomMode Mode { get; } float MinZoom { get; } float MaxZoom { get; }
- IReadOnlyList<float> StepLevels { get; } float DefaultZoom { get; }
- float ClampZoom(float raw)
Usage
Examples
using UnityEngine;
using ZOA.Crosshair.Core;
using ZOA.Crosshair.Unity.Components;
public sealed class PlayerWeaponController : MonoBehaviour
{
[SerializeField] private CrosshairView crosshairView;
private ICrosshairService Crosshair => crosshairView.Service;
public void OnWeaponEquipped(CrosshairDefinition definition)
{
// Swapping identity resets zoom to the new definition's
// clamped DefaultZoom, so the rifle's 4x does not follow the
// player onto the pistol.
Crosshair.SetActive(definition);
}
public void OnAimChanged(bool aiming) => Crosshair.SetAdsActive(aiming);
// Scope cycling wraps, and is a no-op outside Stepped mode.
public void OnScopeCycle(int direction) => Crosshair.CycleZoomStep(direction);
private void Update()
{
// Cosmetic setters: these update fields the view reads each
// frame and deliberately do not raise Changed.
Crosshair.SetSpread(CurrentSpread01());
Crosshair.SetRecoilOffset(RecoilOffsetInScreenPixels());
}
private float CurrentSpread01() => 0f;
private Vector2 RecoilOffsetInScreenPixels() => Vector2.zero;
}using System;
using UnityEngine;
using ZOA.Crosshair.Core;
// Serializable POCO so the definition's SerializeReference field can
// hold it and the Inspector can drive its parameters.
[Serializable]
public sealed class LadderCrosshair : IProceduralCrosshair
{
[SerializeField, Range(0f, 0.3f)] private float _gap = 0.06f;
[SerializeField, Range(0.02f, 0.2f)] private float _rungLength = 0.08f;
[SerializeField, Range(0.004f, 0.03f)] private float _thickness = 0.012f;
[SerializeField, Range(1, 5)] private int _rungs = 3;
public void Build(in CrosshairState state, ICrosshairStrokeBuilder b)
{
// Contract geometry inversely with magnification so the aim
// point gets finer as the optic climbs.
float zoom = state.Zoom > 1f ? 1f / state.Zoom : 1f;
var centre = new Vector2(0.5f, 0.5f);
float thickness = _thickness * zoom;
float gap = (_gap + 0.09f * state.Spread) * zoom;
// Coordinates are normalised to the reticle box: (0,0) bottom
// left, (1,1) top right. The view maps them to pixels.
b.Dot(centre, 0.014f * zoom);
for (int i = 0; i < _rungs; i++)
{
float y = centre.y - gap - i * (_rungLength * 0.6f) * zoom;
float half = (_rungLength * (1f - i * 0.25f)) * zoom * 0.5f;
b.Line(new Vector2(centre.x - half, y),
new Vector2(centre.x + half, y),
thickness);
}
// Explicit colour overrides the theme accent for one stroke.
if (state.IsAdsActive)
b.Add(CrosshairStroke.Circle(centre, 0.18f * zoom, thickness)
.WithColor(new Color(1f, 1f, 1f, 0.25f)));
}
}using UnityEngine;
using ZOA.Crosshair.Core;
// The scope-owns-view configuration, as it looks on the asset:
//
// Class = CrosshairClass.Sniper
// Hipfire = SniperCrosshair
// Ads = null
// HideOnAds = true <- procedural reticle steps aside
// Mode = ZoomMode.Stepped
// StepLevels = { 1, 2, 4, 8 }
// DefaultZoom = 1
//
// With that shape, ResolveActiveReticle returns null while aiming and
// ScopeFrameOverlay takes the screen instead.
public static class ZoomProbe
{
public static void Describe(CrosshairDefinition definition)
{
// ClampZoom is the single validator every setter routes through.
Debug.Log(definition.ClampZoom(3.4f)); // Stepped: snaps to 4
Debug.Log(definition.ClampZoom(99f)); // Stepped: snaps to 8
Debug.Log(definition.ClampZoom(0.1f)); // Analog: clamps to MinZoom
}
}Read this
Notes and caveats
See also