Pre-alphaFoundry is in development. Tools, APIs, and sample content may change.

Browse documentation
Presentationcom.zoa.crosshair · v0.1.0

ZOA Crosshair

Author procedural reticles that respond to the weapon’s state.

Crosshair draws a reticle from live spread, recoil, aiming, and zoom data. CrosshairDefinition supplies the hip-fire reticle and an optional aiming variant. If no aiming reticle is assigned, the definition controls whether hip-fire geometry remains visible or is hidden.

The UI Toolkit view draws strokes supplied by an IProceduralCrosshair implementation. Use a built-in archetype or provide your own geometry, then connect the service to the active weapon. Scoped presentation and field-of-view changes have separate components, so inspect their settings alongside the reticle.

Referenced by ZOA manifests (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.

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Reference

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

Reference

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

Driving the reticle from a weapon controllercsharp
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;
}
Resolve the view on the rig rather than a global service. Split-screen rigs each carry their own reticle state.
Writing a custom procedural reticlecsharp
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)));
    }
}
Append to the supplied builder and allocate nothing. Build runs whenever the reticle is dirty, which on an active weapon is most frames.
Authoring a scoped weapon in codecsharp
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
    }
}
HideOnAds pairs the sniper hipfire reticle with the eyepiece overlay. Clearing it keeps hipfire painting with tightened spread instead.

Integration

Behavior to account for

See also