Assembly Structure

RCE ships as three assemblies. The core assembly contains all engine logic and has no render-pipeline dependency. Two optional assemblies provide pipeline-specific integration — only one compiles per project depending on which Unity render pipeline package is installed.

AssemblyPurposeCompile Condition
RCE.Runtime Core engine: data types, loadout management, SDF rendering, animation, transitions, tracer system, UI, serialization, and all shared utilities. References only Unity.RenderPipelines.Core.Runtime. Always
RCE.Runtime.URP Contains RCERendererFeature — a ScriptableRendererFeature that injects RCE render passes into URP's render graph. When com.unity.render-pipelines.universal is present
RCE.Runtime.HDRP Contains RCEHDRPCustomPass — a CustomPass that injects RCE rendering into HDRP's After Transparent pass. When com.unity.render-pipelines.high-definition is present
Why Three Assemblies?

Unity's assembly definition system compiles each .asmdef independently. By isolating pipeline-specific code into optional assemblies with version defines, the core RCE.Runtime assembly never references URP or HDRP types directly. This means the same package installs cleanly in both URP and HDRP projects with zero user configuration — no scripting defines, no conditional compilation flags, no "choose your pipeline" setup wizards.

The dependency graph is strictly one-directional:

RCE.Runtime.URP
RCE.Runtime
RCE.Runtime.HDRP

Pipeline assemblies reference the core; the core never references pipeline assemblies. Pipeline assemblies never reference each other. This guarantees that removing a pipeline package never breaks compilation.


Directory Layout

The engine is organized by responsibility under Assets/RCE/Runtime/. Each folder maps to a distinct concern:

Assets/RCE/Runtime/
Assets/RCE/Runtime/
├── Bootstrap/          # RCEInstaller, service locator, startup
├── Core/               # Interfaces (ILoadoutManager, IDataPathProvider) and constants
├── Data/               # Enums & value types (ShapeType, ShapeData, GapMode, AnimationMode, etc.)
├── Loadout/            # LoadoutData, LayerData, CategoryData, TransitionSettings, serialization
├── Rendering/          # ReticleRenderer, LoadoutRendererBridge, GlobalFXBridge, GridRenderer
│   └──                # CategoryTransitionAnimator, ShapePropertyAnimator, CustomSDFGenerator
├── Tracers/            # TracerManager, TracerData, TracerSDF shader integration
├── Palettes/           # PaletteData, preset color palettes, palette serialization
├── Utilities/          # Easing, WaveformEvaluator, SDFEvaluator, UndoManager, MathHelpers
├── UI/                 # RCEEditorController, UXML layouts, USS stylesheets, theme files
├── Shaders/            # ReticleSDF.shader, ReticleSDFCore.hlsl, GridSDF, LayerConnectorLines, TracerSDF
├── Resources/          # ExactPolylineSDF.compute, materials, runtime-loaded assets
└── URP/                # RCERendererFeature.cs (separate assembly: RCE.Runtime.URP)
FolderContents
BootstrapEngine initialization. RCEInstaller registers services with the service locator. Entry point for both editor and runtime contexts.
CorePublic interfaces that decouple engine subsystems. ILoadoutManager is the primary API surface for game developers. IDataPathProvider abstracts file storage paths.
DataAll enums (ShapeType, GapMode, AnimationMode, TransitionType, etc.) and the critical ShapeData struct that must match the HLSL layout byte-for-byte.
LoadoutManaged-side data model. LoadoutData is the root; it contains CategoryDataLayerDataShapeAnimationEntry. JSON serialization with version migration lives here.
RenderingThe GPU pipeline. ReticleRenderer owns the StructuredBuffer and issues draw calls. Bridge classes (LoadoutRendererBridge, GlobalFXBridge) sit between the data model and the renderer. Transition and animation processors live here.
TracersBullet tracer/bolt rendering. Separate StructuredBuffer and TracerSDF.shader for projectile trails.
PalettesPreset and user-defined color palettes. Serialized alongside loadouts.
UtilitiesShared math: easing curves (20+ types), waveform evaluation, C# SDF evaluation for edit-time anchor placement, undo/redo engine.
UIThe visual editor built with Unity's UI Toolkit. UXML layouts, USS styles, theme files (Dark/Light/Cobalt/Forest/HotPink), and the 7600-line RCEEditorController.
ShadersAll HLSL. The main ReticleSDF.shader contains two SubShaders (URP + HDRP) that share ReticleSDFCore.hlsl. Supporting shaders for grid, layer connectors, tracers, and custom shape overlay.
ResourcesAssets loaded via Resources.Load at runtime. The ExactPolylineSDF.compute shader for custom shape SDF generation, default materials, and fallback assets.
URPSingle file: RCERendererFeature.cs. Lives in its own assembly definition (RCE.Runtime.URP.asmdef) that references URP and RCE.Runtime.

Data Flow

Every frame, shape data flows through a five-stage pipeline from the loadout manager to the GPU. Each stage has a single responsibility and a well-defined handoff to the next.

Main Reticle Pipeline

LoadoutManager
CategoryTransitionAnimator
ShapePropertyAnimator
LoadoutRendererBridge
ReticleRenderer
GPU
StageClassResponsibility
1. State & Emission LoadoutManager Holds the active LoadoutData and responds to SetActiveState() calls. Emits the current category's layers as a ShapeData[] array, merging HUD shapes (always-on) with the active combat state's shapes.
2. Transition CategoryTransitionAnimator Intercepts state changes. If a transition is configured for this state pair, it captures the outgoing shape array as "from" and the incoming array as "to", then interpolates between them over the configured duration using the selected transition type (Morph, CrossFade, Pop, Slide, etc.) and easing curve. Outputs the interpolated ShapeData[] each frame until the transition completes.
3. Animation ShapePropertyAnimator Evaluates all active ShapeAnimationEntry definitions for the current shapes. Computes additive deltas (multiplicative for scale) from the configured waveform mode and applies them to the base ShapeData values. Handles delay, ramp in/out, loop modes, and animation sequences. Pauses during active transitions.
4. Sanitize & Upload LoadoutRendererBridge Receives the final animated ShapeData[], performs safety checks (NaN clamping, invisible shape zeroing), and calls StructuredBuffer.SetData() to upload the array to the GPU.
5. Render ReticleRenderer Issues a single DrawProcedural call that renders a full-screen quad. The ReticleSDF fragment shader reads the StructuredBuffer<ShapeData> and evaluates every shape's SDF per-fragment.

Global FX Pipeline (Parallel)

Global layers (hit markers, damage flashes, persistent HUD widgets) flow through a parallel pipeline that shares the animation and rendering stages but uses its own bridge and renderer instance:

LoadoutManager
GlobalFXBridge
ShapePropertyAnimator
ReticleRenderer (2nd instance)
GPU

The GlobalFXBridge filters Global category layers by visibility state (shown/hidden/playing), handles fire-and-forget lifecycle, and emits only visible shapes. Because this is a completely separate renderer instance, global effects are never interrupted by combat state transitions on the main pipeline.


ShapeData Struct

The ShapeData struct is the fundamental contract between C# and HLSL. It is 144 bytes, tightly packed with [StructLayout(LayoutKind.Sequential)], and must match the HLSL struct definition byte-for-byte. Any misalignment causes shapes to render with corrupted properties.

ShapeData.cs — 144 bytes, sequential layout
[StructLayout(LayoutKind.Sequential)]
public struct ShapeData
{
    public Vector4 color;           // 16 bytes — RGBA fill color
    public Vector4 outlineColor;    // 16 bytes — RGBA outline color (alpha > 0 enables outline)
    public Vector2 position;        //  8 bytes — UV-space position
    public Vector2 scale;           //  8 bytes — X/Y scale
    public float   rotation;        //  4 bytes — radians
    public float   glowIntensity;   //  4 bytes — glow brightness multiplier
    public float   glowSize;        //  4 bytes — glow distance falloff
    public float   feather;         //  4 bytes — edge softness (0 = hard)
    public int     shapeType;       //  4 bytes — ShapeType enum
    public int     gapMode;         //  4 bytes — bit-packed (see below)
    public int     gapShapeType;    //  4 bytes — bit-packed (see below)
    public int     groupShapeIndex; //  4 bytes — boolean group operand index
    public Vector4 shapeParams;     // 16 bytes — type-specific parameters
    public float   gapCount;        //  4 bytes
    public float   gapThickness;    //  4 bytes
    public float   gapRotation;     //  4 bytes
    public float   taperAmount;     //  4 bytes
    public int     isOutline;       //  4 bytes — true outline toggle
    public float   outlineThickness;//  4 bytes
    public int     customSDFIndex;  //  4 bytes — Texture2DArray slice index
    public int     _padding;        //  4 bytes — alignment padding
}                                    // Total: 144 bytes

Bit-Packed Fields

Two integer fields pack multiple flags into their bits to minimize struct size while maintaining 4-byte alignment:

FieldBitsMeaning
gapMode0–5Gap mode enum: 0 = None, 1 = Radial, 2 = Grid, 3 = Lines, 4 = CenterCutout
6AsSDF flag — Apply gap as SDF boolean (geometry subtraction) instead of alpha mask
7Invert flag — Reverse gap behavior: reveal only inside gap areas
gapShapeType0–7CenterCutout shape: 0 = Circle, 3–8 = polygon with N sides
8LineBurst mode — 0 = ThroughCenter, 1 = Radial
Stadia uses the entire field differently: (symmetry << 4) | (capStyle << 2) | markDirection
Bit packing — C# encode / HLSL decode
// C# — Packing gapMode
int packed = (int)gapMode
          | (asSDF   ? 64  : 0)   // bit 6
          | (invert  ? 128 : 0);  // bit 7

// HLSL — Unpacking gapMode
int gapModeVal = shape.gapMode & 0x3F;       // bits 0-5
bool asSDF    = (shape.gapMode & 64) != 0;   // bit 6
bool invert   = (shape.gapMode & 128) != 0;  // bit 7

// C# — Packing gapShapeType for LineBurst
int packed = cutoutShape
          | (lineBurstRadial ? 256 : 0);  // bit 8

// C# — Packing gapShapeType for Stadia
int packed = markDirection
          | (capStyle << 2)
          | (symmetryMode << 4);
Alignment Rule

GPU structured buffers require 16-byte alignment on most platforms. The ShapeData struct is 144 bytes (9 × 16), which satisfies this constraint. If you ever add fields, ensure the total remains a multiple of 16 by adjusting the padding field. Misalignment causes silent data corruption that manifests as randomly flickering shapes.


Shader Architecture

The ReticleSDF.shader is the heart of the rendering system. It contains two SubShaders that target each supported render pipeline, with all SDF evaluation logic shared in a common include file.

SubShader Organization

SubShaderTagsUsage
SubShader 0"RenderPipeline" = "UniversalPipeline"Selected by URP. Uses URP vertex/fragment macros and render state.
SubShader 1"RenderPipeline" = "HDRenderPipeline"Selected by HDRP. Uses HDRP includes and custom pass conventions.

Both SubShaders include ReticleSDFCore.hlsl, which contains all shared logic:

ReticleSDFCore.hlsl — Fragment shader pipeline
StructuredBuffer<ShapeData> _ShapeBuffer;
int _ShapeCount;
Texture2DArray _CustomSDFArray;

half4 FragReticle(Varyings input) : SV_Target
{
    float2 uv = input.uv;
    half4 result = 0;

    for (int i = 0; i < _ShapeCount; i++)
    {
        ShapeData shape = _ShapeBuffer[i];

        // Early-out for invisible shapes
        if (shape.color.a <= 0 && shape.glowIntensity <= 0) continue;

        // 1. Transform UV into shape-local space
        float2 localUV = TransformToLocal(uv, shape);

        // 2. Evaluate SDF for this shape type
        float dist = EvaluateSDF(localUV, shape);

        // 3. Apply gap mode (alpha mask or SDF boolean)
        dist = ApplyGaps(dist, localUV, shape);

        // 4. Boolean group compositing
        dist = ApplyBooleanGroup(dist, i, shape);

        // 5. Glow (exponential falloff from boundary)
        result += EvaluateGlow(dist, shape);

        // 6. Outline (band outside shape boundary)
        result += EvaluateOutline(dist, shape);

        // 7. Fill with feathering (fwidth-based AA)
        result = ComposeFill(result, dist, shape);
    }
    return result;
}

SDF Evaluation

The EvaluateSDF() function dispatches to the appropriate SDF primitive based on shapeType:

Shape TypeSDF FunctionCharacteristic
Circle (0)sdCircle(p, r)Simple length(p) - r
Ring (1)sdCircle + hollowCircle SDF with abs(d) - thickness hollowing
Box (2)sdBox(p, b)Axis-aligned signed distance
RoundedBox (3)sdRoundedBox(p, b, r)Box with corner radius parameter
LineBurst (4)sdLineBurst(p, ...)N lines/rays with taper, through-center or radial distribution
Chevron (5)sdChevron(p, angle)V-shape with configurable opening angle
Polygon (6)sdPolygon(p, n, r)Regular 3–8 sided polygon with corner radius
Stadia (8)sdStadia(p, ...)Ranging marks: main line + major/minor ticks + caps
ChamferBox (9)sdChamferBox(p, b, c)Box with beveled corners (octagonal shape)
Custom (100)Texture2DArray samplePre-computed Bézier SDF from _CustomSDFArray

Screen-Space Anti-Aliasing

All shapes use fwidth() for screen-space adaptive feathering. This intrinsic returns the sum of partial derivatives of the SDF distance in screen space, providing a pixel-width measurement that produces uniform edge quality regardless of shape size or camera distance. When feather is set to 0, the AA pass is bypassed entirely for pixel-perfect hard edges — useful for thin crosshair lines.


Custom Shape SDF Pipeline

Custom freeform shapes (ShapeType 100) use a multi-stage compute shader pipeline to generate exact signed distance fields from cubic Bézier curves. Unlike analytical shapes that are evaluated per-fragment in real time, custom shapes are pre-computed into a texture and sampled at render time.

Pipeline Stages

Control Points
Cubic Béziers
ExactSDF Kernel
BlurCurvature Kernel
Texture2DArray
StageWhat Happens
Control Points User-defined CustomControlPoint list with position, handleIn, handleOut, and point type (Corner/Smooth/Symmetric). Edited interactively in the visual editor.
Cubic Béziers Adjacent control points form cubic Bézier curves. The curve array is uploaded to a StructuredBuffer for the compute shader.
ExactSDF Kernel Per-texel, finds the nearest point on the nearest Bézier curve. Uses a coarse search (16 subdivisions per curve) followed by Newton-Raphson refinement (4 iterations) for sub-pixel accuracy. Outputs: R = signed distance, G = second-nearest distance (non-adjacent curves only, for concave glow), A = raw analytical curvature (κ = |x'y'' − y'x''| / |v'|³).
BlurCurvature Kernel Gaussian-smooths the raw curvature from channel A into channel B. Uses an 11×11 grid at 16-texel spacing (σ ≈ 48 texels) to erase sharp Voronoi boundary seams between curve neighborhoods. The smoothed curvature drives glow tapering in the fragment shader.
Texture2DArray Final output: 1024² ARGBHalf texture per custom shape, stored in a Texture2DArray with up to 16 slots. CustomSDFGenerator orchestrates the pipeline, manages slot allocation, and dispatches both kernels sequentially.

Texture Channel Layout

ChannelContentUsed For
RSigned distance to nearest boundaryFill, outline, feathering, boolean ops — all standard SDF operations
GSecond-nearest distance (d2), non-adjacent curves onlyConcave glow: light pools in tight concavities where two distant boundary segments are close
BGaussian-smoothed curvatureGlow tapering: glow *= exp(−κ × glowDist × 1.5) attenuates glow at sharp convex tips (e.g. crescent ends)
ARaw analytical curvatureInput to the BlurCurvature kernel; not used directly in the fragment shader
Performance Note

The compute shader runs once when control points change — not per frame. At runtime, custom shapes are a single texture sample per fragment, which is actually cheaper than the ALU-heavy analytical SDF evaluation used by standard shapes. The tradeoff is the fixed 8 MB memory cost per custom shape slot (1024² × ARGBHalf).


Component Resolution

RCE's runtime contains zero FindFirstObjectByType calls. All component references are resolved through three deterministic patterns, chosen based on the reference context:

Pattern 1: Serialized References

Scene-assigned references via [SerializeField]. Used when both the referencing component and the target live in the same scene hierarchy.

RCEEditorController.cs — Serialized references
public class RCEEditorController : MonoBehaviour
{
    [SerializeField] private ReticleRenderer     _reticleRenderer;
    [SerializeField] private GridRenderer        _gridRenderer;
    [SerializeField] private LayerConnectorRenderer _layerConnectorRenderer;
    [SerializeField] private CategoryTransitionAnimator _transitionAnimator;
    [SerializeField] private TracerManager       _tracerManager;
}

This is the preferred pattern. References are validated at scene save time, are visible in the Inspector, and have zero runtime cost.

Pattern 2: Static Instance Properties

Singleton-style Instance properties for components that need to be accessed by ScriptableRendererFeature subclasses, which cannot hold scene references because they live on asset objects.

GridRenderer.cs — Static Instance pattern
public class GridRenderer : MonoBehaviour
{
    public static GridRenderer Instance { get; private set; }

    void OnEnable()  => Instance = this;
    void OnDisable() => Instance = null;
}

// Used by RCERendererFeature (ScriptableRendererFeature)
var grid = GridRenderer.Instance;
if (grid != null && grid.IsVisible)
    grid.EnqueuePass(context);

Components using this pattern: GridRenderer.Instance, LayerConnectorRenderer.Instance, CustomShapeOverlayRenderer.Instance, and ReticleRenderer.Instances (a list, since multiple renderers can exist for main + Global FX).

Pattern 3: Runtime Resolution

On-demand component discovery via TryGetComponent with AddComponent fallback. Used for components that are lazily created or optionally present.

LoadoutRendererBridge.cs — Runtime resolution
// Resolve transition animator from the same GameObject
if (!TryGetComponent(out _transitionAnimator))
    _transitionAnimator = null; // optional, transitions disabled

// Resolve or create shape property animator
var rendererGO = _reticleRenderer.gameObject;
if (!rendererGO.TryGetComponent(out _shapePropertyAnimator))
    _shapePropertyAnimator = rendererGO.AddComponent<ShapePropertyAnimator>();

// On-demand custom shape overlay (only when editing custom shapes)
_customShapeOverlayRenderer = gameObject.AddComponent<CustomShapeOverlayRenderer>();
Why Not FindFirstObjectByType?

FindFirstObjectByType performs a linear scan of all loaded objects every call. In a game with thousands of GameObjects, this is measurably expensive and non-deterministic (the "first" object depends on internal load order). RCE's three patterns — serialized, static, and runtime resolution — are all O(1) and produce deterministic, inspector-visible results.


What's Next