← Keyline on 4ssets Quick Start Manual Live demo ▶

Keyline

Stylised inverted-hull outlines for URP and HDRP — with an X-Ray pass, interior fills, merge groups and thirteen drop-in components for the things outlines are usually bought for.

1 · Overview

Keyline draws an outline by building a second copy of the mesh, pushing it outward and drawing it behind the original. Everything in this manual follows from that one sentence.

It is a per-object effect. There is no fullscreen pass, no depth-normals prepass and nothing to add to a renderer feature list: an object has an outline because it has the component, and objects without it are not touched. That is the trade against a post-process edge detector, which sees the whole frame at once and gives every edge in it the same weight. If you want one uniform line across the entire image, a post-process is the right tool. If you want this enemy outlined in orange through a wall while that pickup pulses green, this is.

1.1 What is in the box

Two properties are worth stating before anything else, because they are what most outline assets ask you to give up.

Your material is never touched. Keyline does not swap the shader on your renderer, does not require a specific one, and does not add a pass to it. The outline is drawn from a separate hull with its own material, so the source object keeps whatever it had — Lit, Shader Graph, a third-party toon shader, an asset-store character with a stack of custom passes. Nothing has to be re-authored to be outlinable, and removing the component leaves the object exactly as it was.

It adapts rather than demands. The same component covers a static prop, a skinned character, a flat card and a texture-cut leaf; it picks the hull it can build from the mesh in front of it. Pipeline, colour space, shadow settings and quality level are read, not dictated — there is no renderer feature to add, no prepass to enable and no project setting the package insists on.

1.2 Requirements

Unity6000.3 or newer
PipelinesURP 17.x and HDRP 17.x — one package, both supported
Built-in RPnot supported: the shader carries a SubShader per SRP and picks by tag
Tested onWindows (URP and HDRP), Android (Vulkan and OpenGLES3), WebGL
UntestediOS and VR — nothing in the package is platform-specific, but they have not been run, and this table says what was measured rather than what is expected
Both pipelines live in the same package and the same shader. Which SubShader compiles is decided by the pipeline tag, and which of the two the editor tooling assumes is decided by a define the package refreshes on import.

2 · Install

  1. Import the package. Everything lands in Assets/4ssets/Keyline/.
  2. Wait for the compile. The package looks at which pipeline packages are installed and writes the matching defines itself — if that ever goes stale, force it with Tools ▸ 4ssets ▸ Keyline ▸ Refresh Pipeline Defines.
  3. Open Demo/Demo.unity and press Play.

Nothing else is required. There is no renderer feature to add, no volume profile to author and no layer to reserve.

The demo scene retargets its own materials. It ships authored against one pipeline's Lit shader; opened under the other, that shader does not exist and Unity would substitute the magenta error shader. The package notices and fixes it on first load — only the demo's own materials, and only when their shader is actually missing. If it ever has not, Tools ▸ 4ssets ▸ Keyline ▸ Fix Demo Materials For This Pipeline runs it by hand. The outline shaders are never involved: they carry a SubShader for each pipeline and pick by tag.
The demo scene, X-Ray chapter
The demo, ten chapters of it, is the fastest documentation in the package — this is the X-Ray chapter with the prop halfway behind the wall.

2.1 Package layout

FolderHoldsNeeded at runtime
Runtime/ the component, the profile asset, the merge group, the recipes, the default materials yes
Shaders/ the outline shader, the Light variant, the generated pipeline header yes
Editor/ inspectors, the bake tool, the variant stripper, the export helper no — stripped from builds by the assembly definition
Patterns/ 84 textures for the Pattern style only if you use them
Demo/ the demo scene, its scripts, models, character and cutout textures no

Deleting the demo is safe and expected. It is the largest folder in the package and nothing in Runtime/ references it. Delete Demo/ whole rather than in parts: it contains a Resources folder — Unity puts everything in one of those into every build whether or not it is used — plus a WebGL plugin used only by the demo's HUD.

The same applies to Patterns/ if you never use the Pattern style, and to the example art in general. See Licence and credits: it is public domain, so keeping, changing or deleting it is entirely yours to decide.

3 · Concepts

3.1 The inverted hull

The component builds a hidden copy of every mesh under it — a mirror — pushes its vertices outward and draws it with front faces culled, behind the original. The copy is bigger, so it shows past the edges of the real object and nowhere else. That ring is the outline.

Everything else in this manual is a consequence of the direction those vertices are pushed:

Width is authored either in world centimetres or in screen pixels. World keeps the outline in proportion to the object, so it thins with distance; Screen holds the same pixel count at any distance, which is what a marker on a far objective needs and what turns a crowd of distant objects into a solid mass.

3.2 The four passes

One component draws up to four things, each with its own switch and its own profile asset:

PassDrawsTypical use
Main the rim, where the object is visible the ordinary outline
X-Ray the rim, only where the object is behind something an objective through a wall; a teammate through terrain
Fill the object's own surface, not a hull around it damage flashes, gauges, dissolves
X-Ray Fill the surface, only where the object is hidden a solid silhouette through geometry

The passes are independent in both directions: a solid rim around a perforated fill is a valid combination, and so is a thin grey Main rim with a bright orange X-Ray one. Each pass reads a complete profile, so "its own style" means its own everything.

The four passes, one per quadrant
One object, one pass at a time. Note that the two X-Ray quadrants only show anything where the wall is in front of the prop.
Rim only against rim plus fill
Left: the rim on its own. Right: the same object with the interior fill switched on. The fill is the surface, which is why it has no thickness to speak of.
A fill has no width. It is the surface itself, drawn at width 0 — the controller forces that, so the Width field on a fill profile is ignored. Colour, opacity and the style are what shape it.

3.3 Profiles are shared assets

A profile is a ScriptableObject. Assign one to a hundred enemies and there is one object in memory, which is exactly what you want for memory and for the SRP Batcher — and exactly what you do not want the moment gameplay writes to it. settings.color = Color.red on one enemy turns all hundred red, and in the editor the asset stays modified after play mode ends, because a ScriptableObject edited at runtime is the same object that sits on disk.

Instance Override is the answer and it is on by default. With it on, the first write forks the slot: the controller clones the asset, points the slot at the clone and writes there. Reads before and after return the same values, so nothing observable changes except that the shared asset is now safe. Forking is lazy — an object that only ever reads keeps sharing.

The rule that follows: go through the component, not the profile. outline.Color = Color.red forks; outline.Settings.color = Color.red does not, because at that point the profile is a plain object and nobody is watching. See 12.2.

3.4 The stencil budget

Outlines have to know where they are allowed to draw, and the answer is written in the stencil buffer. The pool hands out 127 unique pairs for a scene — enough for 127 objects whose outlines are independent of each other at the same time.

Past that, further objects fall back to a shared pair, and outlines that share a pair stop fencing each other off: where they overlap, the result is decided by whichever drew last. On a crowd this reads as flicker.

Two things spend from that budget, and both give something back:

So the practical limit is not "127 outlined objects" but "127 things that need to be told apart from each other". A forest of shrubs that always overlap is one of them.

4 · The component

Keyline Outline is the only component you have to add. Everything else in the package either sits on top of it or is optional.

4.1 Outlines

Four rows, one per pass: a profile slot and a switch. The slots are independent and can be filled in any order — a pass with no profile simply does not draw, and its switch is a switch on nothing.

RowWhat it holds
Main Outlinethe rim profile
Main Interior Fillthe fill drawn where the object is visible
XRay Outlinethe rim drawn where the object is hidden
XRay Interior Fillthe fill drawn where the object is hidden
The Keyline Outline inspector
All four slots filled and all four passes on. A slot with no profile draws nothing, and its switch has nothing to switch.

4.2 Setup

FieldDefaultWhat it does
Enabled On Starton Whether the outline draws from the first frame. Off is the usual choice for anything a highlight component switches on later.
Instance Overrideon Runtime writes fork the profile into a copy this object owns, instead of editing the shared asset. Leave it on unless you specifically want one object's script to change every object sharing that profile. See 3.3.
Outline Materialempty Empty uses the package material for the current shader profile. Assign your own only if you have modified the shader — the controller drives dozens of properties on it.
Smooth Normalson Averages normals across hard edges when the hull is built, so a cube's corners do not tear the outline open. Costs a one-time analysis when the outline is built.
Include Childrenon Outlines every renderer under this transform, not just the one on it. What makes a multi-part prop read as one object.
Ignore Children Listempty Exceptions to the above — a hitbox, a socket, a glow card you do not want traced.
Layer Maskeverything Which renderers under this object are eligible at all.
Preview In Edit Modeon Builds the mirrors in the editor so the outline is visible without entering play mode.

4.3 Tools

5 · The profile

A profile is an asset: Assets ▸ Create ▸ Keyline ▸ Settings, or the New button next to any empty slot. Ten profiles ship in Runtime/Profiles/, one per style, and Tools ▸ 4ssets ▸ Keyline ▸ Reset Bundled Style Profiles puts them back if they are edited by accident.

The inspector hides what the current style does not read. That is deliberate and it goes both ways: a control that does nothing is worse than a missing one, because it invites tuning and then teaches the wrong lesson.

The profile inspector on the Gradient style
The Gradient style with the Radial shape. The inspector shows what this style and this shape read, and hides the rest — Angle becomes Stretch Axis here, and the linear-only fields are gone.

5.1 Look

FieldWhat it does
Style Which of the ten looks this profile draws. See 6.
Color HDR. Its alpha is part of the look, not a master fade — that is Opacity.
Secondary Color Only for the styles that use two: Double, Gradient, Frost. Under Gradient it is the start of the ramp and its alpha is the alpha at that end — a transparent start against an opaque main colour is what makes a gauge empty out instead of changing colour.
Width Thickness of the rim. Metres in World mode, pixels in Screen mode.
Width Mode World keeps the outline in proportion to the object, so it thins with distance and goes away with the thing it is on. ConstantScreen holds the same pixel width at any distance — right for a marker on a far objective, wrong for a crowd, which turns into a solid mass of outline.
Opacity Master fade for the whole pass, 0 to 1. This is the one to animate.
Face Cull Front is the classic inverted hull. Both is for single-sided sheets that must read from either side. Ignored by fills, which are not hulls.

5.2 Style space

Styles that draw a pattern, a noise or a ramp need to know what that pattern is attached to. Style Space is that answer, and only those styles read it.

SpaceThe pattern is anchored toReads as
ObjectSpacethe meshpainted on; it turns with the object
WorldSpacethe scenethe object moves through a fixed field
ScreenSpacethe screena filter over the image
UVSpacethe mesh's unwrapa texture on the surface — needs sane UVs

5.3 Expand

FieldWhat it does
Expand Mode Normal pushes along the surface normal, Planar stays in the plane of the face, Mixed decides per face. The whole of section 7 is about choosing between them.
Miter Limit How far a vertex on a hard edge may be pushed beyond the plain offset before the corner is cut short. 1 leaves corners alone; higher keeps sharp corners sharp at the cost of long spikes on very acute ones.
Expand Center Mode Where in-plane expansion radiates from: the mesh's own bounds, or a point you give it. On a model whose bounds centre sits off the shape — a hilt, a stalk — that origin is what makes one side of the outline heavier than the other.
Planar Width / Height Scale Stretch the in-plane expansion along the object's local X and Y. For sheets that should grow more one way than the other.
Sheet Solidify and its scales Gives detected sheets a thickness so they stop being infinitely thin. Covered in 7, where it can be shown rather than described.
Under Cutout this entire section is ignored: the hull is not extruded at all, and the rim is drawn by widening the texture's alpha instead. The inspector hides Expand when Cutout is on, for exactly that reason.

5.4 Pulse

A cheap animated breath, computed on the CPU once per frame and pushed as two numbers — no coroutine, no animator, no per-object script.

FieldWhat it does
PulseOn or off.
Pulse SpeedCycles per second, roughly.
Pulse AmountHow far it swings.

On a rim, Pulse drives the width and brightens the colour with it. On a fill it drives the colour only, because a fill has no width to swing.

6 · Styles

Ten looks, one profile field. Switching the style in the inspector or in the demo brings that style's own defaults with it — colour, width and its own parameters — because a style is a specific set of numbers rather than just a branch in the shader.

Cost. Seven of the ten are one hull and one draw call per object. Three are not, and it is worth knowing which before a crowd scene:

StyleHulls per objectWhy
Solid, Sketch, Pattern, Electric, Rainbow, Gradient, Neon1one band, drawn once
Frost2a soft shell around the core
Double3inner ring, outer ring, and a mask that keeps the gap empty
Halo2–8one per layer — the layer count is the draw count

All ten shots below are the same prop, the same camera and the same light. What differs is the style and its shipped defaults.

6.1 Solid

One hull, one draw call — the cheapest the asset gets, and the right default for anything that just needs to be picked out. Every other style is measured against this one.

The Solid style
The reference: an even contour of constant width, and nothing else happening.

6.2 Neon

An additive core that reads as light rather than paint. Glow Intensity pushes the colour past white; the shipped profile also switches Pulse on, because a neon sign that does not breathe looks like a decal.

Additive blending means the background shows through the bright parts — over a dark scene it glows, over a bright one it washes out. That is the trade of the style, not a fault of it.

The Neon style
Additive core. The brightness comes from Glow Intensity, not from the colour being lighter.

6.3 Halo

A falloff instead of a line: several concentric hulls, each slightly wider and slightly more transparent than the last. Layers is how many, Edge Alpha is the alpha at the outer one, Fade Strength is the shape of the ramp between them.

Each layer is a draw call. Eight layers on one hero object is fine; eight layers on forty objects is three hundred and twenty draws.
The Halo style
Five layers, linear falloff. What you are looking at is the gradient, not a thick line.

6.4 Sketch

Hand-drawn unevenness: the width is perturbed by noise, redrawn at a discrete rate rather than every frame, so it reads as a pen redrawing the outline instead of static jitter. Amount is how uneven, Speed is how often it is redrawn.

The Sketch style
The width varies along the contour. Speed controls the redraw rate, not the motion.

6.5 Pattern

A texture across the outline — dashes, dots, hazard stripes, whatever is in the 84 shipped PNGs or your own. Scale, Contrast, Threshold and Scroll shape it.

What the pattern is anchored to is Style Space: painted on the mesh, fixed in the world, fixed on screen, or following the UVs.

The Pattern style
A pattern with obvious structure reads best. Scroll it and the outline becomes a marching ant.

6.6 Double

Two concentric rings with a gap between them. Inner Width, Gap and Outer Width are all in the profile's width units.

Three draws per object: the two rings, plus a mask that keeps the gap genuinely empty rather than filled by whatever is behind.

The Double style
Two rings and a real gap — the mask is what keeps the middle from filling in.

6.7 Electric

Sketch's noise driven harder and faster, with a floor under the width so the bolt cannot collapse to nothing between spikes. Shares Amount and Speed with Sketch; the shipped profile roughly doubles both.

The Electric style
A frame at the peak of a spike. The floor under the width is why the line never breaks.

6.8 Rainbow

A hue sweep along the outline. Motion chooses whether the bands travel up the object or rotate around a centre; Scale is how many bands, Speed how fast — negative reverses it.

Saturation takes the ramp towards grey rather than towards white, so the bands stay readable as light and dark all the way down to zero. A desaturated Rainbow is a travelling shimmer that does not fight the scene's palette.

Rainbow is the one style that does not read Colour: every channel comes from the hue ramp, so the field is hidden for it rather than left there doing nothing. Opacity and Saturation work as expected.

The Rainbow style
Vertical motion. Circular puts the centre wherever the offsets say, which is what a shield wants.

6.9 Gradient

The largest style in the asset, and the one everything else is built on: the waves, the gauges and the reveal all drive a Gradient underneath.

Two colours with independent alphas, three shapes and a set of controls per shape — see 5 for the full field list. In short: Linear is a ramp in the object's own space; Radial and Planar measure from a point or a plane in absolute world space, which is what lets one front cross several objects in step.

The band is symmetric. With Band above zero the ramp becomes a travelling stripe, and the second colour is then what the object looks like everywhere the stripe is not — not a trailing colour. Give it alpha 0 unless you want the whole model tinted.
The Gradient style
Linear, one colour at each end. Drive Offset from code and the ramp becomes a level or a wave.

6.10 Frost

A soft, cold edge: a wide low-opacity shell around a core, with sparkle noise on top. Soft Falloff shapes the shell, Shell Width and Shell Opacity size it, Sparkle is the noise. Uses the secondary colour.

Two hulls per object — the core and the shell.

The Frost style
Shell plus sparkle. The second colour is doing half the work here.

7 · Hard meshes

This is the section the asset exists for. Everything else here can be reproduced with a short inverted-hull shader from a forum post; this cannot.

7.1 Why a normal-based hull fails

Pushing every vertex along its normal works while the mesh has volume. Where it does not — a leaf, a blade, a card, a thin prong — the normals on the two sides of the sheet point in opposite directions, so the two sides move apart. A flat leaf becomes a wedge. A fork's prongs, thin and close together, swell until they touch and the gaps between them close.

The three pairs below are the same prop, the same width and the same camera, with one switch between them.

Carrot: naive hull against Keyline
The carrot is one mesh of two kinds: a solid root and flat leaves. A single expand rule cannot serve both — push the leaves along their normals and they inflate into blades.
Fork: naive hull against Keyline
Thin prongs, close together. The naive hull closes the gaps and the fork becomes a paddle; four prongs and four gaps is the correct answer.
Leek: naive hull against Keyline
The same split as the carrot with the proportions reversed: here the flat part dominates, so the failure takes over the whole silhouette.

7.2 Mixed: deciding per face

Mixed classifies each part of the mesh once, when the outline is built, and then expands solid parts along their normals and sheet parts in their own plane. The classification is a measurement, not a guess: rays are cast from each face to find how far it is to the other side of the surface, and anything thinner than the threshold is a sheet.

SettingWhat it does
Detect mode Thickness casts one ray per face — cheaper, coarser. Rays casts a small cone and takes the consensus, which is what a mesh with noisy normals needs.
Thickness thresholdIn centimetres. Thinner than this counts as a sheet.
FlatnessHow parallel the two sides must be before they count as one sheet.
Rays / Ray coneHow many rays in the cone and how wide it opens.
Min sheet facesRaise it when a few stray triangles on a solid model get classified as a sheet: an island has to be at least this many faces to count.
This runs when the outline is built, not per frame. It costs load time and memory, never frame time. How much load time depends entirely on the mesh: on the demo's props the pause is not measurable above the noise (see 13.4), on a dense character it will be — which is what baking is for.

7.3 Sheet Solidify

Classifying a sheet is half the job. The other half is that a sheet has no thickness at all, so seen edge-on there is nothing for an outline to be. Sheet Solidify gives the detected sheets a shell: a thickness across the surface and a spread within it.

SettingWhat it does
ThicknessHow far the shell is pushed to each side of the sheet.
LateralHow far it grows within the plane — the outline's width on the flat part.
VolumeOne number driving both, when the two do not need separating.
Thicken X / YPush the shell to one side instead of both — a leaf growing off a stem should thicken away from it, not swallow it.

7.4 Baking

The analysis is deterministic, so on shipping content it can be done once instead of on every load. Bake for Runtime on the component — or Tools ▸ 4ssets ▸ Keyline ▸ Bake All Outlines In Scene for the whole scene — writes the prepared hull meshes to assets and points the component at them.

Worth doing when Mixed or Solidify is in use and the content is final. Not worth doing while the look is still being tuned: a baked mesh is a snapshot, and changing the settings that produced it means baking again.

8 · Cutout

With Cutout on, the outline follows the shape the object's texture leaves behind rather than the shape of its mesh. A fence plank whose alpha punches holes in it gets an outline around the holes; a leaf card gets an outline around the leaf, not around the quad it is painted on.

An outline following a punched alpha shape
The mesh is a single flat quad. Everything you see — the contour and the two interior holes — comes from the texture's alpha.

8.1 Setting it up

  1. The source material needs alpha clipping on, with a texture that has an alpha channel. That is the object's own material, not the outline's.
  2. Switch Cutout on in the profile. The outline reads the same texture off the source renderer — _BaseColorMap, _BaseMap or _MainTex, whichever the material has.

Cutoff decides where the alpha is cut. Left at 0 the outline uses the material's own threshold, which is what keeps the two in step; above zero it overrides, which is how you make the outline bite slightly wider or tighter than the model.

8.2 Rim Samples

A hull widens edges the mesh has, and a punched contour is not one of them: no vertex sits on it. So the rim is drawn by widening the alpha itself — the shader samples the texture on a ring around each pixel and keeps the pixel if any of those samples is solid.

Rim Samples is how many samples are on that ring, and it is the cost of the feature: every one of them is a texture fetch on every pixel of the hull.

Two and sixteen rim samples compared
Same width, different sample counts. Too few and the rim goes scalloped; the wider the outline, the more obvious it gets.
SamplesUse
1the ring is off: the outline is clipped by the alpha and has no rim of its own
6clean on a thin rim
12the default — safe for most widths
16–24for a thick rim, where scalloping would show

8.3 What Cutout changes elsewhere

With Cutout on, the hull is not extruded at all and the whole Expand section stops applying — the inspector hides it. In-plane extrusion would slide the surface sideways under a UV that stays put, stretching the punched shape instead of outlining it; off-surface extrusion would separate the hull from the model, invisible head-on and a second parallel line edge-on. Width feeds the alpha ring instead.

Two more consequences worth knowing before shipping it:

8.4 Flat meshes

Cutout wants a flat mesh — fences, foliage cards, decals, sprites in 3D. Single- or double-sided makes no difference; flatness does.

On a solid mesh it works but looks worse: both sides of the object carry the same punched texture, the two project to different places on screen, and the near rim ends up cut into by a contour that belongs to the back of the object. A flat mesh has no far side to disagree with, which is why a fence and a leaf card are what the feature is for.

9 · Merge groups

Two objects side by side get two outlines, and where they overlap you see the seam between them. Sometimes that is right — two enemies are two enemies. Sometimes it is not: a character and the weapon in their hand are one thing, and a contour running through the middle says otherwise.

Separate outlines against a merged silhouette
The same three props, one switch apart: separate contours with seams where they overlap, and one contour around the group.

9.1 Automatic — no component

Objects whose outline bounds overlap are merged by themselves. Nothing to add, nothing to configure: the clustering runs on an interval, and objects that stop overlapping go back to being separate.

This covers most of what people reach for a merge group to do. Push two props into each other and the seam between them disappears on its own.

9.2 Explicit — Keyline ▸ Merge Group

For what automatic merging cannot see: things that stand near each other without touching, and things that must read as one unit however far apart they drift.

Put the component on a parent; every outlined object beneath it belongs to the group. Membership is the hierarchy — reparent a member in or out at runtime and its controller notices by itself. Nothing has to be told, and the group's stencil pair is allocated and released by enabling and disabling the component.

9.3 Which wins

The component. A member of an active group is left out of the automatic clusters entirely, so a group's silhouette is never half-decided by whoever happens to be standing nearby.

9.4 What it costs

A group spends one stencil pair for the whole group, however many members it has — see 3.4. On a crowd that is the cheaper option, not the more expensive one: eight soldiers in a squad cost what one object costs, so the 127-pair budget starts counting squads instead of soldiers.

10 · Skinned meshes

A skinned mesh needs no special handling: add the component and the outline follows the animation.

The hull is a second SkinnedMeshRenderer pointed at the same bones and the same root. Unity skins it in exactly the same pass it skins the original, so there is no per-frame work of Keyline's own — no baking a mesh every frame, no copying vertices, nothing that scales with the animation's complexity. What it costs is one more skinned draw per pass, which is what any second renderer costs.

An animated character with an outline
Mid-animation. The hull is skinned by the same pass that skins the character, so the outline costs nothing per frame beyond one more skinned draw.
Sheet Solidify does not apply to skinned meshes. It works by rebuilding the hull's topology, and rebuilding triangles on a skinned mesh would break the bone weights that make it move. Mixed's per-face decision still applies; only the topology rebuild is skipped.

In practice this rarely matters: characters are volumes, and the flat-sheet machinery exists for props. Where a character does carry a genuinely flat part — a cloak, a cape, a paper charm — give that part its own renderer and its own profile.

11 · Recipes

Thirteen entries under Add Component ▸ Keyline, from the files in Runtime/Recipes/ — twelve recipes plus the arbiter that keeps them from fighting over the same object. They are ordinary components written against the same public API you have — nothing in them reaches into the outline through a back door — and they exist because the same five or six arrangements get written from scratch by everyone who buys an outline asset.

Every one of them is shown in the demo's Interaction chapter, one mini-scene each.

ComponentSolves
KeylineHighlightseveral sources wanting the outline at once
KeylinePointerHighlighthover
KeylineClickSelectionclick to select, shift to add
KeylineTriggerHighlightentering a volume
KeylineProximityHighlightgetting close
KeylineDamageFlasha hit landing
KeylineFillGaugea level painted on the model
KeylineImpactWavea ring from the point of impact
KeylineScanWavea front crossing the scene
KeylineRevealan object materialising or dissolving
KeylineGroupHighlighta squad answering as one
KeylineOccludedXRaythrough-walls only while actually hidden
KeylineBlinkattention, briefly
KeylinePointerone pointer API over both input systems
KeylineTriggerRelaytrigger callbacks reaching a parent
IKeylineHighlightTargetone object or a group, told apart by nobody

11.1 Highlight — the arbiter

Three scripts writing the same colour is the bug everybody writes once. Hover the object, select it, move the pointer away — and the hover release clears the selection, because the last writer wins and nobody agreed who the last writer should be.

KeylineHighlight holds named states with priorities. Anything can push a state and pop it later; the highest priority wins, and releasing it falls back to the next one still held rather than to nothing.

var highlight = target.GetComponent<KeylineHighlight>();

highlight.Push("Hover");      // priority 10
highlight.Push("Selected");   // priority 50 — this is what shows
highlight.Pop("Hover");       // still selected

Each state carries its own colour, width multiplier and fade time. Every highlight recipe below speaks to this component rather than to the outline, which is why they compose.

11.2 Pointer Highlight and Click Selection

Both live on one object in the scene, not on the props. One raycast per frame answers for everything, where a script per prop would cast one ray per prop to answer a question that has a single answer.

A prop takes part by having a collider and a KeylineHighlight. Nothing is registered, nothing is wired, and the ray looks past colliders with no highlight on them — so a hitbox in front of the model does not swallow the hover.

Pointer Highlight
Cameraempty uses Camera.main
State Idwhich state to push; default Hover
Layers, Max Distancewhat the ray may hit and how far
Blocked By UIignore hovers while the pointer is over uGUI
Prefer Groupsresolve to the group a prop belongs to, not the prop
Click Selection
State Iddefault Selected
Max Selection1 is classic single select; 0 is unlimited
Clear On Empty Clickclicking nothing clears
Toggle With Modifiershift or ctrl adds and removes

Selection is a held state, not a colour written into the object — which is why selecting something the pointer is also over does not fight with the hover.

11.3 Trigger and Proximity Highlight

Trigger Highlight lights the object while something is inside a trigger collider — a zone, a doorway, a pickup radius. Filter by layer and by tag.

Unity delivers OnTriggerEnter to the collider's own GameObject, not to a parent. KeylineTriggerRelay is added automatically when the zone sits on a child, and forwards the callbacks up. Worth knowing because it is the reason a trigger on a child works at all.

Proximity Highlight needs no collider: it measures distance to a target — assigned, or found by tag — and can fade the highlight between a near and a far radius rather than switching it. The check runs on an interval, not every frame.

11.4 Damage Flash

A critical hit flash
Peak of a critical hit. The flash lives on the interior fill, so the rim keeps whatever it was doing.

A hit, drawn on the model through the interior fill. Two intensities out of the box — Flash() and FlashCritical() — with their own colours and glow.

var flash = enemy.GetComponent<KeylineDamageFlash>();

flash.Flash();                        // ordinary hit
flash.FlashCritical();                // heavier colour, brighter
flash.FlashFrom(hit.point);           // the flash starts where it was struck

The directional form takes a world point and has two shapes: a falloff spreading from the point of impact, or the struck half of the object lit and the other half left alone. The axis is built from the outline's bounds centre rather than the transform, because a prop's pivot is usually at its feet.

11.5 Fill Gauge

A gauge drawn on the model
The level is a boundary sliding across the surface, not a bar over the head — it rides the object because it is drawn on the object.

Health, charge, build progress — drawn on the object rather than over its head. Nothing is masked, clipped or re-meshed: the level is one shader parameter, so it costs the same at any value and animates for free.

var gauge = enemy.GetComponent<KeylineFillGauge>();

gauge.SetValue(health, maxHealth);    // or SetValue(0..1)
gauge.SnapTo(1f);                     // no animation

Calibration is the part to read. The sweep runs across the mesh's bounds, and a model that does not fill its bounds evenly — greens on top, a tapering root below — reaches empty and full well away from the theoretical −0.5 and 0.5. Set the level to 0 and drag Offset At Empty until the fill just vanishes, then the level to 1 and Offset At Full until it just covers the model.

Tint When Low shifts the fill towards a warning colour below a threshold — a level and a warning are different pieces of information, and a bar that is only shorter makes you work the second one out. Smoothing is how long the drawn level takes to catch up: 0 snaps, and a quarter of a second turns a number changing into a hit landing.

11.6 Impact Wave and Scan Wave

Both are built on the Gradient style's world-space shapes, and both use the same two colours: Core at the middle of the front and Edge everywhere else.

The band is symmetric. Edge is not a trailing colour — it is what the object looks like everywhere the front is not. Give it alpha 0 unless you want the whole model tinted while the wave is somewhere else entirely.

Impact Wave is per-object: a ring spreading from a point you give it, with its own speed, reach, thickness and a fade in / hold / fade out envelope.

Scan Wave lives on one object in the scene and drives a list of controllers: a plane travelling along an axis, crossing everything in its path in step. It writes only to the objects the front is actually touching, so a scan across a room does not cost anything on the objects it has already passed.

11.7 Reveal

An object materialising
Mid-climb: the fill has reached partway up the silhouette and the mesh is not there yet.

An object arriving. The mesh starts hidden, the fill climbs the silhouette, and when it reaches the top the outline flares and the real mesh appears under the flare.

var reveal = spawned.GetComponent<KeylineReveal>();

reveal.Play();          // materialise
reveal.PlayReverse();   // dissolve, flash first

The flare is not decoration. Drop it to zero and the swap becomes visible: the fill vanishes and the lit mesh appears in its place, and the two never quite match. Hiding that is the only reason it is there.

11.8 Group Highlight

One squad highlighted as a single silhouette
Point at one member and the squad answers — as one silhouette, and for one stencil pair.

Put it on the parent of a group and the members answer together: point at one, the squad lights up. With Merged on, the group draws as a single silhouette with no seams where the bodies overlap — and spends one stencil pair for the whole squad rather than one per body, so the budget in 3.4 counts squads instead of soldiers.

Membership is the hierarchy. Reparent a member in or out at runtime and its controller notices by itself.

11.9 Occluded X-Ray

Left on permanently, a through-walls outline stops meaning anything: it looks the same when the objective is in plain sight and when it is behind a building, so the player learns to ignore it. This component ties the X-Ray pass to real occlusion — the marker appears exactly when the thing it marks disappears.

Occlusion is tested with a line cast from the camera, on an interval, and the object's own colliders are skipped. There is a short clear delay so walking past a railing does not make the marker strobe, and separate fade-in and fade-out times, because appearing is the information and disappearing is only the absence of it.

Include Interior Fill switches the X-Ray fill on with the rim: a filled silhouette reads across a level, a rim alone is quieter and scales to more objects at once.

Pushes and pops a state on a timer — on for so long, off for so long, so many times, or until stopped. For a quest object that should be noticed once rather than outlined forever.

blink.Play();      // the authored count
blink.Play(3);     // three times
blink.Stop();      // and release the state

12 · Scripting

12.1 The everyday API

using FourSsets.Keyline;

var outline = target.GetComponent<KeylineOutline>();

outline.MainOutlineEnabled = true;              // switch the rim on
outline.Color = Color.red;                      // recolour it
outline.Width = 0.04f;                          // metres, in World width mode
outline.XRayEnabled = true;                     // show it through walls too

That is most uses. The properties on the component cover every field of the profile — colour, width, style, glow, the gradient, the halo, the lot — and they all write through the same guard described below.

The component is KeylineOutline. KeylineOutlineController is its base class, where the implementation lives; you can type against either, and the properties are the same.

12.2 Instance Override

Writing to a profile at runtime writes to a shared asset — see 3.3. With Instance Override on, the first write through a component property forks that slot into a copy this object owns. It is lazy: an object that only reads never allocates anything.

// Safe: goes through the property, forks on first write.
outline.Color = Color.red;

// NOT safe: the profile is a plain object here and nobody is watching.
outline.Settings.color = Color.red;   // every object sharing this profile turns red

When a whole batch of fields is changing at once, ask for the copy and write to it directly:

KeylineOutlineSettings mine = outline.ForkSlot(KeylinePassKind.Primary);

mine.color = Color.red;
mine.width = 0.05f;
mine.glowIntensity = 3f;
mine.ValidateValues();
outline.RefreshAppearance();          // push it without rebuilding geometry
MemberDoes
InstanceOverrideon by default; turning it off does not un-fork what is already forked
ForkSlot(kind)fork one slot now and hand back the copy
ForkAllSlots()all four at once
IsSlotForked(kind)whether this slot is running on a copy
SharedSlotSettings(kind)the asset the slot held before it forked — the look to return to
RevertSharedSettings()put the shared assets back and destroy the copies

RevertSharedSettings() is the cheap way to undo everything gameplay did to an object's look: rather than tracking which of forty fields were touched, drop the copy and the original asset is the look again.

12.3 Driving the passes

Four switches and four profiles, addressed by KeylinePassKind:

outline.SetPassEnabled(KeylinePassKind.XRay, true);
bool on = outline.IsPassEnabled(KeylinePassKind.MainInterior);
KeylineOutlineSettings fill = outline.PassSettings(KeylinePassKind.MainInterior);

To edit a pass that is not the active one through the component's own properties, bracket the writes:

outline.BeginSettingsEdit(KeylinePassKind.XRay);
outline.Color = Color.orange;
outline.Width = 0.03f;
outline.EndSettingsEdit();

Refresh or rebuild

Most changes are values the shader reads — colour, opacity, glow, gradient offset. Those need RefreshAppearance(), which is cheap and can run every frame.

Some changes alter how many hulls exist or how they are built: the style (Halo's layer count, Double's rings), the expand mode, Solidify, the feature mask. Those need Rebuild(), which throws the mirrors away and makes them again. The component notices this by itself when you go through its properties — the distinction matters when you write to a forked profile directly.

12.4 InvalidateCutout

Cutout resolves the texture off the source renderer's material and caches it against that material's instance id. Swapping the whole material is noticed by itself. Writing a different texture into the same material is not — the id has not changed, and re-resolving on every appearance push would put a property lookup per renderer on a path that runs whenever anything moves.

// A damage state, a variant, a seasonal skin:
mat.SetTexture("_BaseMap", damagedTexture);

// Tell the outline to look again.
outline.InvalidateCutout();

It clears the cache for all four passes and refreshes — the X-Ray rim reads the same texture as the Main one.

12.5 Adding the component at runtime

Adding the component and giving it a profile is the whole of it, in either order:

var outline = enemy.AddComponent<KeylineOutlineController>();
outline.Settings = hostileProfile;

Any slot will do — an object that only ever shows a through-wall marker can fill XRaySettings and leave Settings empty. The outline comes up as soon as the first slot is filled, whether that happens in the same statement, later in the frame, or several frames afterwards. Enabled On Start decides whether it comes up visible.

If you want it added but not yet showing, leave Enabled On Start off and call outline.SetEnabled(true) when the moment arrives. Calling that before a profile is assigned is also fine: it is remembered, and the outline appears when the profile does.

13 · Performance

Three things decide what an outline costs: how many hulls the style builds, whether the draws batch, and how many pixels they cover. The first two are measurable and are measured below; the third is fill rate and depends on your screen and your widths.

13.1 The SRP Batcher

Outlines batch. Measured in the demo's Performance chapter, URP, one Merge Group over the whole grid, style Solid:

Objects with an outlineSRP batches in the transparent queue
2004
4007

Not "4 draw calls" — 4 batches. The two largest at 400 objects hold 166 and 167 draw calls each. Doubling the object count added three batches.

What makes that possible is worth stating plainly, because it decides whether Instance Override is affordable: every object in that grid has its own profile and its own material instance, and they batch anyway. The SRP Batcher keeps material properties in a per-material constant buffer and switches a pointer rather than the pipeline state, so distinct materials on the same shader variant stay in one batch. The batches that do break report a device state change, not a material difference.

What gives the batcher up

Anything that needs a MaterialPropertyBlock, because a block is per-renderer data the batcher cannot fold into its buffer. Two features use one:

With Cutout on, the same frame debugger shows the outline leaving RenderLoop.DrawSRPBatcher for plain RenderLoop.Draw, one event per draw, and Unity gives the reason itself:

DrawBatch cause reported by Unity
the rimObjects have different materials.
the interior maskSRP: Node is not compatible with SRP batcher

The object's own mesh keeps batching — it is only the outline that drops out. On a fence that is nothing; on a forest it is the first thing to measure. Absolute gradient radius keeps the batcher, and is the reason that mode exists.

13.2 Hulls per object

Draw count follows the style's topology, and it is the one cost you can predict without measuring:

StyleHulls per object
Solid, Neon, Sketch, Pattern, Electric, Rainbow, Gradient1
Frost2 — core plus shell
Double3 — two rings and the gap mask
Haloone per layer, 2 to 8

13.3 The stencil budget

127 unique pairs per scene, and merging is how you spend fewer: a merge group takes one pair for the whole group, and overlapping outlines cluster automatically. The grid measured above is one group, which is why every batch in it shares a single stencil reference.

13.4 Frame time

Measured in a standalone player at 1080p, VSync off, in the demo's Performance chapter — a grid of identical props, one Merge Group, style Solid unless stated. Each figure is the midpoint of a 60-frame average watched until it settled.

The machine these numbers come from
GPUGeForce RTX 4070 Laptop
CPUIntel Core i9-14900HX
Memory / OS32 GB · Windows 11
Unity6000.3 · URP 17.x

What the outline itself costs

Objects outlinedOutlines offOutlines onDifference
641.72 ms1.92 ms+0.19 ms
2001.81 ms2.01 ms+0.19 ms
4001.91 ms2.13 ms+0.23 ms
Read these as an order of magnitude, not as a benchmark. The frame-to-frame spread in each configuration was 0.15–0.44 ms — the same size as the difference being measured. The honest statement is that on this hardware, at this resolution, four hundred outlined objects cost about a fifth of a millisecond and the cost barely moves between 64 and 400. Not that it is exactly 0.19.

That flatness is the batching in 13.1 showing up in the frame time: the work does not scale with the object count until something forces it out of the batcher.

What a style costs

200 objects, same scene:

StyleFrameAgainst Solid
Solid2.00 ms
Double2.18 ms+0.19 ms
Halo, 5 layers2.30 ms+0.30 ms

Double draws three hulls per object and Halo five, but neither costs three or five times Solid. At this scale the frame is not draw-bound — which is another way of saying the draw-call table in 13.2 predicts the shape of the cost, not the milliseconds.

The Light profile

ProfileFrame, 200 objects
Full2.02 ms
Light2.04 ms

No measurable difference on a desktop GPU, and that is the expected result. Light exists for build size and shader variant count, and for fragment cost on hardware where the fragment stage is the bottleneck. On a 4070 it buys nothing you can see in a frame — pick it for mobile, not for this.

Mixed and the build pause

Building the 200-object grid, wall clock:

ExpandRun 1Run 2
Normal11 ms11 ms
Mixed, first build14 ms11 ms
Mixed, cache warm12 ms11 ms

On these props the sheet analysis does not show up: the spread between two runs of the same configuration is as large as the difference between configurations. Mixed still costs build time rather than frame time — that part is structural, it runs when the hull is built and never again — but on meshes of this size the pause is not something a buyer will notice. On a dense character mesh it will be, which is what baking is for.

13.5 Allocation

Profiled over a steady frame in the Performance chapter, CPU Hierarchy, GC Alloc column: everything allocated in the frame comes from IMGUI — 36.4 KB in the demo HUD's OnGUI plus 4.5 KB in Unity's own GUI plumbing. Outside that subtree the frame allocates nothing.

The outline runtime does not allocate per frame: no garbage from the mirrors, the property blocks or the merge clustering. The 40 KB in the demo is the price of an IMGUI panel, and it leaves with the demo.

13.6 Android

The demo was run on Android under both Vulkan and OpenGLES3, with the Full shader profile, and all ten chapters behave as they do on the desktop. Full is not a desktop-only setting and the mobile-facing features are not cut down there: Cutout, Mixed and the ten styles are all present.

Light is still the faster profile, and the gap widens with the number of outlined objects. On one hero character it is not worth thinking about; on a crowd it is the difference you will measure. The profile decides how many shader variants exist and how much work each fragment does, so the cost it saves is per pixel covered by an outline — which is why object count, outline width and screen resolution all push in the same direction.

The sensible default on mobile is Light, moving to Full only when you reach for something it does not carry — see 14.5 for what that is. On desktop there is no reason not to run Full.

14 · Troubleshooting

Every entry here is a real constraint of the shipped build rather than a defect: the asset behaves this way on purpose, and the surprise is worth documenting because the behaviour is not guessable.

14.1 Nothing is drawn at all

14.2 Changing one object changed all of them

The profile is a shared asset and the write went straight to it. In the editor the change also survives play mode, because a ScriptableObject edited at runtime is the object on disk.

Write through the component (outline.Color = …), which forks the profile into a copy this object owns, rather than through outline.Settings.color, which does not. See 12.2.

14.3 The cutout outline keeps the old texture

A new texture was written into the same material. The resolved texture is cached against the material's instance id, which has not changed, so nothing signalled that anything did. Call outline.InvalidateCutout() after the swap — 12.4.

14.4 Cutout looks wrong on a solid model

Known, and the reason the demo shows cutout on a flat sheet. Both sides of a solid object carry the same punched texture, the two project to different places on screen, and the near rim ends up cut into by a contour belonging to the back of the object.

Cutout is at its best on flat meshes — fences, foliage cards, decals, sprites in 3D. Single- or double-sided makes no difference; flatness does.

14.5 Cutout or Mixed does nothing on the Light profile

The Light shader has neither: no clip branch, and no vertex path for Mixed — its hull is Normal or Planar and nothing else. That absence is most of where the saving comes from. The trade is flat and thin meshes, which Light gets wrong.

Switch the profile to Full, or accept the limitation deliberately rather than tuning against it.

14.6 Width does nothing on a fill

A fill is the object's own surface, not a hull around it, and the controller forces its width to zero. Colour, opacity and the style are what shape it — 3.2.

14.7 The whole model is tinted while the wave is elsewhere

The gradient's band is symmetric. Its second colour is not a trailing colour — it is what the object looks like everywhere the front is not. Give it alpha 0 unless the tint is wanted.

14.8 Outlines interfere on a crowd

The stencil pool hands out 127 unique pairs per scene. Past that, further objects share a pair, and objects sharing a pair stop fencing each other off — where they overlap the result is decided by draw order, which reads as flicker.

Merge what belongs together: a group spends one pair however many members it has, and overlapping outlines cluster automatically. See 3.4.

14.9 The SRP Batcher broke

Any MaterialPropertyBlock takes a renderer out of the batcher, and two features use one:

Both are worth the cost where they are needed and worth avoiding on a crowd. Absolute radius keeps the batcher.

14.10 The demo scene is magenta

Its materials were authored against the other pipeline's Lit shader. The package retargets them on load; if it has not, Tools ▸ 4ssets ▸ Keyline ▸ Fix Demo Materials For This Pipeline. The outline shaders are never involved — they carry a SubShader per pipeline.

14.11 Sheet Solidify does nothing on a character

Topology is never rebuilt for a skinned source: the hull is a second SkinnedMeshRenderer on the same bones, and rebuilding its triangles would break the bone weights. Solidify needs to rebuild topology, so it is skipped there — 10.

15 · Reference

15.1 Menu paths

MenuEntry
Add ComponentKeyline ▸ Keyline Outline
Add ComponentKeyline ▸ Merge Group
Add ComponentKeyline ▸ Highlight (arbiter), and thirteen more recipes
Assets ▸ CreateKeyline ▸ Settings
Tools4ssets ▸ Keyline ▸ Bake All Outlines In Scene
Tools4ssets ▸ Keyline ▸ Strip Unused Shader Variants
Tools4ssets ▸ Keyline ▸ Refresh Pipeline Defines
Tools4ssets ▸ Keyline ▸ Reset Bundled Style Profiles
Tools4ssets ▸ Keyline ▸ Fix Demo Materials For This Pipeline

15.2 Enumerations

All in namespace FourSsets.Keyline.

KeylineStyle

Solid · Neon · Halo · Sketch · Pattern · Double · Electric · Rainbow · Gradient · Frost — see 6.

KeylinePassKind

Primarythe Main rim
XRaythe rim drawn where the object is hidden
MainInteriorthe fill
XRayInteriorthe fill drawn where the object is hidden

KeylineWidthMode

Worldmetres; the outline keeps its proportion to the object
ConstantScreenpixels; the same thickness at any distance

KeylineExpandMode

Normalalong the surface normal — right for volume
Planarin the plane of the face — right for sheets
Mixedper face, whichever of the two applies

KeylineExpandCenterMode

MeshBoundsin-plane expansion radiates from the mesh's bounds centre
ObjectPivotfrom the transform's own origin
Customfrom a point given in object space

KeylineFaceCull

Frontdraw front faces — the classic inverted hull
Backdraw back faces
Bothdraw both — for single-sided sheets seen from either side

KeylineStyleSpace

ObjectSpacethe pattern is painted on the mesh
WorldSpacethe object moves through a fixed field
ScreenSpacethe pattern stays put on screen
UVSpacethe pattern follows the unwrap

KeylineGradientShape

Lineara ramp along Angle, in the object's own space
Radiala sphere spreading from a world point
Planara plane travelling along a world axis

KeylineGradientRadiusMode

RelativeRadius is multiplied by the object's own size — one setting fits every model
AbsoluteRadius is metres — what a front crossing several objects needs

KeylineMixedDetectMode

Thicknessone ray per face; cheaper, coarser
MultiRaya cone of rays per face; slower to bake, far fewer misses

KeylineRainbowMotion

Verticalbands travel up the object
Circularbands rotate around a centre

KeylineShaderProfile

Fullevery style, cutout, Mixed expand
LightSolid only, Normal or Planar expand, no cutout — fewer variants and less fragment work

KeylineShaderFeatures

A [Flags] mask recording which shader variants a build needs: None, Cutout, StyleNeon, StyleHalo, StyleSketch, StylePattern, StyleDouble, StyleElectric, StyleRainbow, StyleGradient, StyleFrost, All.

The mask does not decide what may render — the style does. It records what the build has to keep, and its style bits are derived from the Style field automatically. That separation exists because the old behaviour let a profile sit on a style whose bit was off and render silently as Solid.

15.3 Shader properties

The outline material is driven by the controller, and almost every property on it is written every time the appearance is applied. Setting them yourself is possible and pointless — the next refresh overwrites them.

The two shaders are 4ssets/Keyline/Outline (Full) and 4ssets/Keyline/OutlineLight (Light). The controller picks between them from the profile's Shader Profile field and swaps the material with it.

If you fork the shader, keep the property names: the controller addresses them by Shader.PropertyToID and a renamed property silently stops being written rather than failing loudly. Assign your copy in the component's Outline Material slot.

16 · Licence and credits

Keyline is licensed under License.txt in the package root — the Unity Asset Store EULA governs, and that file states in plain words what it means here.

The example art in the package — pattern textures, cutout textures, demo models and the demo character — is by Kenney and is in the public domain under CC0 1.0 Universal. Every component, its path and the full licence text are listed in Third-Party Notices.txt, also in the package root. CC0 asks for no attribution; the credit here is given because the work deserves it.

Keyline is made by 4ssets, part of UnityCraft.org. Questions, bugs and requests go to the same address.