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
- Four independent passes per object — the rim, an X-Ray rim for when the object is hidden, and an interior fill for each. Every pass carries its own complete profile.
- Ten styles — Solid, Neon, Halo, Sketch, Pattern, Double, Electric, Rainbow, Gradient, Frost.
- Hard-mesh handling — the part that is hard to write yourself: flat leaves, single-sided cards and thin prongs, which a naive hull turns into blades.
- Merge groups — several objects as one silhouette, explicitly or automatically.
- Thirteen drop-in components in
Runtime/Recipes/— hover, selection, damage flash, gauges, waves, reveal, and the arbiter that keeps them from fighting. - A demo of ten chapters that is also the fastest documentation in the package.
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
| Unity | 6000.3 or newer |
|---|---|
| Pipelines | URP 17.x and HDRP 17.x — one package, both supported |
| Built-in RP | not supported: the shader carries a SubShader per SRP and picks by tag |
| Tested on | Windows (URP and HDRP), Android (Vulkan and OpenGLES3), WebGL |
| Untested | iOS 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 |
2 · Install
- Import the package. Everything lands in
Assets/4ssets/Keyline/. - 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. - Open
Demo/Demo.unityand press Play.
Nothing else is required. There is no renderer feature to add, no volume profile to author and no layer to reserve.
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.
2.1 Package layout
| Folder | Holds | Needed 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:
- Along the normal — right for anything with volume, wrong for a flat sheet, where the normals all point the same way and the copy simply moves towards the camera.
- In the plane of the face — right for a sheet, wrong for a sphere, which has no plane to stay in.
- Per face, deciding which of the two applies — what Mixed does, and the reason a carrot with flat leaves and a solid root can have one outline.
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:
| Pass | Draws | Typical 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.
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:
- A merge group spends one pair for the whole group, however many members it has. A squad of eight costs what one object costs.
- Automatic merging clusters objects whose outline bounds overlap and gives the cluster one pair, for as long as they overlap.
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.
| Row | What it holds |
|---|---|
| Main Outline | the rim profile |
| Main Interior Fill | the fill drawn where the object is visible |
| XRay Outline | the rim drawn where the object is hidden |
| XRay Interior Fill | the fill drawn where the object is hidden |
4.2 Setup
| Field | Default | What it does |
|---|---|---|
| Enabled On Start | on | Whether the outline draws from the first frame. Off is the usual choice for anything a highlight component switches on later. |
| Instance Override | on | 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 Material | empty | 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 Normals | on | 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 Children | on | Outlines every renderer under this transform, not just the one on it. What makes a multi-part prop read as one object. |
| Ignore Children List | empty | Exceptions to the above — a hitbox, a socket, a glow card you do not want traced. |
| Layer Mask | everything | Which renderers under this object are eligible at all. |
| Preview In Edit Mode | on | Builds the mirrors in the editor so the outline is visible without entering play mode. |
4.3 Tools
- Rebuild Mirrors — throws the hulls away and builds them again. Needed only after changing the source meshes from outside the component.
- Bake for Runtime — writes the analysed hull meshes to assets, so the analysis does not run on load. Worth it for Mixed and Solidify on shipping content; see 7.
- Log Pass Diagnostics — dumps what each pass built, to the console. The first thing to run before reporting a bug.
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.
5.1 Look
| Field | What 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.
| Space | The pattern is anchored to | Reads as |
|---|---|---|
ObjectSpace | the mesh | painted on; it turns with the object |
WorldSpace | the scene | the object moves through a fixed field |
ScreenSpace | the screen | a filter over the image |
UVSpace | the mesh's unwrap | a texture on the surface — needs sane UVs |
5.3 Expand
| Field | What 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. |
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.
| Field | What it does |
|---|---|
| Pulse | On or off. |
| Pulse Speed | Cycles per second, roughly. |
| Pulse Amount | How 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:
| Style | Hulls per object | Why |
|---|---|---|
| Solid, Sketch, Pattern, Electric, Rainbow, Gradient, Neon | 1 | one band, drawn once |
| Frost | 2 | a soft shell around the core |
| Double | 3 | inner ring, outer ring, and a mask that keeps the gap empty |
| Halo | 2–8 | one 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Setting | What 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 threshold | In centimetres. Thinner than this counts as a sheet. |
| Flatness | How parallel the two sides must be before they count as one sheet. |
| Rays / Ray cone | How many rays in the cone and how wide it opens. |
| Min sheet faces | Raise 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. |
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.
| Setting | What it does |
|---|---|
| Thickness | How far the shell is pushed to each side of the sheet. |
| Lateral | How far it grows within the plane — the outline's width on the flat part. |
| Volume | One number driving both, when the two do not need separating. |
| Thicken X / Y | Push 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.
8.1 Setting it up
- 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.
- Switch Cutout on in the profile. The outline reads the same texture off the source
renderer —
_BaseColorMap,_BaseMapor_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.
| Samples | Use |
|---|---|
| 1 | the ring is off: the outline is clipped by the alpha and has no rim of its own |
| 6 | clean on a thin rim |
| 12 | the default — safe for most widths |
| 16–24 | for a thick rim, where scalloping would show |
8.3 What Cutout changes elsewhere
Two more consequences worth knowing before shipping it:
- Cutout is a Full-profile feature. The Light shader has no clip branch at all, which is part of what makes it light.
- It costs the SRP Batcher. The texture is resolved per source renderer and bound through
a
MaterialPropertyBlock. Cheap on a fence, worth measuring on a forest.
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.
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.
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.
| Component | Solves |
|---|---|
KeylineHighlight | several sources wanting the outline at once |
KeylinePointerHighlight | hover |
KeylineClickSelection | click to select, shift to add |
KeylineTriggerHighlight | entering a volume |
KeylineProximityHighlight | getting close |
KeylineDamageFlash | a hit landing |
KeylineFillGauge | a level painted on the model |
KeylineImpactWave | a ring from the point of impact |
KeylineScanWave | a front crossing the scene |
KeylineReveal | an object materialising or dissolving |
KeylineGroupHighlight | a squad answering as one |
KeylineOccludedXRay | through-walls only while actually hidden |
KeylineBlink | attention, briefly |
KeylinePointer | one pointer API over both input systems |
KeylineTriggerRelay | trigger callbacks reaching a parent |
IKeylineHighlightTarget | one 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 | |
|---|---|
Camera | empty uses Camera.main |
State Id | which state to push; default Hover |
Layers, Max Distance | what the ray may hit and how far |
Blocked By UI | ignore hovers while the pointer is over uGUI |
Prefer Groups | resolve to the group a prop belongs to, not the prop |
| Click Selection | |
|---|---|
State Id | default Selected |
Max Selection | 1 is classic single select; 0 is unlimited |
Clear On Empty Click | clicking nothing clears |
Toggle With Modifier | shift 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.
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 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
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.
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 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
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.
11.10 Blink
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.
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
| Member | Does |
|---|---|
InstanceOverride | on 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.
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 outline | SRP batches in the transparent queue |
|---|---|
| 200 | 4 |
| 400 | 7 |
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:
- Cutout. The source texture is resolved per renderer and bound through a block.
- Relative gradient radius on an object with several renderers, where the size the radius is relative to differs per source.
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:
| Draw | Batch cause reported by Unity |
|---|---|
| the rim | Objects have different materials. |
| the interior mask | SRP: 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:
| Style | Hulls per object |
|---|---|
| Solid, Neon, Sketch, Pattern, Electric, Rainbow, Gradient | 1 |
| Frost | 2 — core plus shell |
| Double | 3 — two rings and the gap mask |
| Halo | one 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 | |
|---|---|
| GPU | GeForce RTX 4070 Laptop |
| CPU | Intel Core i9-14900HX |
| Memory / OS | 32 GB · Windows 11 |
| Unity | 6000.3 · URP 17.x |
What the outline itself costs
| Objects outlined | Outlines off | Outlines on | Difference |
|---|---|---|---|
| 64 | 1.72 ms | 1.92 ms | +0.19 ms |
| 200 | 1.81 ms | 2.01 ms | +0.19 ms |
| 400 | 1.91 ms | 2.13 ms | +0.23 ms |
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:
| Style | Frame | Against Solid |
|---|---|---|
| Solid | 2.00 ms | — |
| Double | 2.18 ms | +0.19 ms |
| Halo, 5 layers | 2.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
| Profile | Frame, 200 objects |
|---|---|
| Full | 2.02 ms |
| Light | 2.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:
| Expand | Run 1 | Run 2 |
|---|---|---|
| Normal | 11 ms | 11 ms |
| Mixed, first build | 14 ms | 11 ms |
| Mixed, cache warm | 12 ms | 11 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
- The pass has no profile. A slot with no Settings asset draws nothing and its switch has nothing to switch — see 4.1.
- Enabled On Start is off and nothing has switched the outline on since.
- Width is 0 on a rim pass. On a fill that is normal; on a rim it is invisible by definition.
- The renderer is excluded: Include Children is off, the renderer is in the Ignore Children List, or its layer is outside the Layer Mask.
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:
- Cutout, which resolves the source texture per renderer.
- Relative gradient radius on an object with several renderers, where the size that radius is relative to differs per source.
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
| Menu | Entry |
|---|---|
| Add Component | Keyline ▸ Keyline Outline |
| Add Component | Keyline ▸ Merge Group |
| Add Component | Keyline ▸ Highlight (arbiter), and thirteen more recipes |
| Assets ▸ Create | Keyline ▸ Settings |
| Tools | 4ssets ▸ Keyline ▸ Bake All Outlines In Scene |
| Tools | 4ssets ▸ Keyline ▸ Strip Unused Shader Variants |
| Tools | 4ssets ▸ Keyline ▸ Refresh Pipeline Defines |
| Tools | 4ssets ▸ Keyline ▸ Reset Bundled Style Profiles |
| Tools | 4ssets ▸ 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
Primary | the Main rim |
XRay | the rim drawn where the object is hidden |
MainInterior | the fill |
XRayInterior | the fill drawn where the object is hidden |
KeylineWidthMode
World | metres; the outline keeps its proportion to the object |
ConstantScreen | pixels; the same thickness at any distance |
KeylineExpandMode
Normal | along the surface normal — right for volume |
Planar | in the plane of the face — right for sheets |
Mixed | per face, whichever of the two applies |
KeylineExpandCenterMode
MeshBounds | in-plane expansion radiates from the mesh's bounds centre |
ObjectPivot | from the transform's own origin |
Custom | from a point given in object space |
KeylineFaceCull
Front | draw front faces — the classic inverted hull |
Back | draw back faces |
Both | draw both — for single-sided sheets seen from either side |
KeylineStyleSpace
ObjectSpace | the pattern is painted on the mesh |
WorldSpace | the object moves through a fixed field |
ScreenSpace | the pattern stays put on screen |
UVSpace | the pattern follows the unwrap |
KeylineGradientShape
Linear | a ramp along Angle, in the object's own space |
Radial | a sphere spreading from a world point |
Planar | a plane travelling along a world axis |
KeylineGradientRadiusMode
Relative | Radius is multiplied by the object's own size — one setting fits every model |
Absolute | Radius is metres — what a front crossing several objects needs |
KeylineMixedDetectMode
Thickness | one ray per face; cheaper, coarser |
MultiRay | a cone of rays per face; slower to bake, far fewer misses |
KeylineRainbowMotion
Vertical | bands travel up the object |
Circular | bands rotate around a centre |
KeylineShaderProfile
Full | every style, cutout, Mixed expand |
Light | Solid 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.
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.
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.