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. A hard-edged outline needs no fullscreen pass, no depth-normals prepass and nothing on a renderer feature list: an object has an outline because it has the component, and objects without it are not touched. A soft 3D rim is optional — one pipeline pass, added with a click, see 4.4. 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
- Six independent passes per object — the rim, an X-Ray rim for when the object is hidden, an interior fill for each, and an optional Cutout Overlay on the Main and the X-Ray rim, which a mesh has and a sprite does not. Every pass carries its own complete profile.
- Fifteen styles — Solid, Neon, Halo, Sketch, Pattern, Double, Electric, Rainbow, Gradient, Frost, Lava, Smoke, Crystal, Water and Fire.
- 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 required for a hard edge, no prepass to enable and no project setting the package insists on. Soft 3D rims optionally add one pass — 4.4.
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 — not run, and this table says what was measured rather than what is expected |
| VR | outlines are stereo-aware and untested; Soft Edge is not. Its shaders address a single eye, so under single-pass stereo — instanced or multiview — the pass switches itself off and says so once in the console. Outlines still render, without the soft edge. Multi-pass stereo draws one eye at a time into ordinary targets and is unaffected |
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 ▸ Keyline ▸ Refresh Pipeline Defines. - Open
Demo/Demo.unityand press Play.
Nothing else is required for a hard-edged outline. There is no renderer feature to add, no volume profile to author and no layer to reserve. A soft 3D rim is optional: one click adds a pipeline pass, and without that click Edge Softness does nothing — see 4.4.
Tools ▸ 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 Mobile Solid variant, the generated pipeline header | yes |
Editor/ |
inspectors, the bake tool, the variant stripper, the build hook that carries the project settings into a player | 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 carries a link.xml and a WebGL plugin that belong to it and to nothing
else.
The link.xml is worth knowing about while you keep the folder. Unity merges every
one it finds under Assets/ into the managed stripping step of every build, and this
one names eight physics types — the colliders and rigidbodies the demo adds from code, which the
stripper cannot see and would otherwise remove, leaving the demo broken in a player build and
working in the editor. It preserves those eight and nothing else, so the cost to a project that
ships no physics is a handful of retained engine classes. Deleting Demo/ takes it
with them.
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 in one of three units. World is metres, and keeps the outline in proportion to the object, so it thins with distance. Screen is pixels, and holds the same count at any distance — what a marker on a far objective needs, and what turns a crowd of distant objects into a solid mass. Pixels is a whole number of the sprite's own texels, and exists because an edge aligned to a texel grid cannot honour anything else; a mesh has no texel grid and ignores it.
3.2 The passes
One component draws up to six things, each with its own switch and its own profile asset — four that every outline has, and two Cutout Overlays that only a mesh offers:
| 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 |
| Main Overlay | a second draw of Main, with its own stencil pair | a hull silhouette and an alpha rim on one object. Mesh only, experimental — see 4.1 |
| X-Ray Overlay | the same second draw, through walls | as above, for the hidden half. Mesh only, experimental |
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
13.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 126 unique pairs for a scene — enough for 126 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.
An object with a Main Overlay - Cutout Outline or an X-Ray Overlay - Cutout Outline assigned spends a second pair for that overlay. The hull still shares whatever silhouette it belongs to; the overlay keeps its own pair so Single Layer on the overlay profile can fence that rim without sharing the hull's stamp.
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. Overlay rims on those members still take their own pairs.
- 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 "126 outlined objects" but "126 things that need to be told apart from each other". A forest of shrubs that always overlap is one of them.
Sharing the stencil with your own effects
Keyline writes the stencil buffer of the camera it draws into, and so may whatever else you have there — a Render Objects feature with a stencil override, a decal system, a custom pass of your own. Two effects writing the same bits do not fail loudly; they take turns, and each looks intermittently wrong.
What Keyline uses:
- All eight bits are spoken for, and not in the same way. Pairs are handed out as whole values from 2 to 254, and a pair's identity lives in bits 1 to 7 — the pool is a range of values, not a set of flags, which is what lets 126 objects be told apart in seven bits at all. Bit 0 is the odd half of each pair and carries the X-Ray hollow stamp, which belongs to the scene rather than to any one object. Between the two there is no bit left over, and the stamps mask off whichever of them they are not asking about.
- Value 64 and 65 are reserved as the shared fallback pair for objects past the pool.
- Zero is left alone — an untouched cell means "nobody has claimed this pixel".
So an effect of yours that writes a fixed stencil value in the same frame will be overwritten by an outline drawn after it, and will overwrite one drawn before it. There is no bit to hide in. The ways out, in order of how well they work:
- Separate the frame. Put your effect before
AfterRenderingTransparents, or after Keyline has finished with the stencil, and clear it between. Outlines read the stencil only within their own draws. - Separate the camera. An overlay camera stack gives your effect its own buffer.
- Turn Soft Edge on for the objects that clash. A soft rim is composited in a private target with its own stencil and never touches the camera's — see 4.4.
3.5 X-Ray see-through layers
The X-Ray passes draw where an object is hidden. Out of the box "hidden" has one meaning for the whole scene — behind anything that wrote depth — and that is often too blunt. An enemy should show through the foliage in front of it and stay hidden behind the wall beside it, and those are the same answer to the depth buffer.
X-Ray Sees Through, in the Setup block of both outline components, narrows it per object.
It is an ordinary LayerMask over GameObject layers:
- An occluder on a listed layer lets this outline's X-Ray rim and X-Ray fill appear behind it.
- An occluder on an unlisted layer hides the object outright, as though X-Ray were switched off.
- Everything — the default — is what Keyline has always done.
The layer being read is the occluder's, not the outlined object's. Put the things you want to see through on a layer of their own, and list that layer on the outlines that may see through it.
Two objects behind the same wall can disagree, which is the point of putting the value on the component rather than in a profile or a scene-wide setting. Nothing anywhere else narrows, widens or overrides it: what the component says is what happens.
Generated child outlines inherit it — a figure is seen through the same things all over. A child carrying its own Keyline component keeps its own value, which is how one part of a model gets a different answer. Members of a merge group need no special rule: they share a silhouette and each draws by its own mask, so a pixel appears when any member both covers it and is allowed through.
It can be changed from script while the game runs, like any other Keyline parameter:
outline.XRaySeeThroughLayers = LayerMask.GetMask("Foliage", "Glass");
Occluders you can see through
Keyline decides what counts as an occluder by the render queue: opaque geometry writes depth, so the buffer this mask is read from agrees with the depth buffer it stands in for. A transparent material with ZWrite switched on is the one case where that rule is wrong. It does hide things — the ordinary X-Ray appears behind it exactly as it would behind a wall — but it sits in the transparent queue, so no mask can name it. Glass, a shop window, a water surface.
Transparent Occluder Layers, under X-Ray Layer Filtering in
Project Settings ▸ Keyline, is where you name the layers those live on. It is
empty out of the box and empty is a complete answer — everything on this page works without
it, and almost every project leaves it alone.
Two things are worth knowing before you fill it in. Geometry on a named layer is collected whether or not it writes depth, so a pane that hides nothing will still block an X-Ray that is not allowed through its layer — keep the list to the layers that do the hiding. And the layer has to be one no outlined object is on, which is what putting occluders on a layer of their own already means.
The setting is stored where the render pipeline can read it in a build — on the renderer feature under URP, with Keyline's HDRP pass switches under HDRP — so the page shows one field either way and there is nothing to keep in step by hand.
What it costs
Nothing, until a mask is actually narrowed. With every outline seeing through everything there is nothing to test: the shader branch is not compiled in, no buffer is allocated and no extra geometry is drawn.
Once something does narrow, Keyline fills one small screen-sized buffer per camera, and — this is the part worth knowing — the cost does not grow with the number of different masks in the scene. Layers every narrowed outline sees through can veto nothing and are skipped entirely; layers no outline sees through all block equally and share one channel. Only layers your outlines actually disagree about need a channel of their own, and a project where every outline uses the same mask needs none. Giving fifty objects fifty different masks costs the same pass as giving one object one.
Three layers may be disputed at once. If more are, the extras fold in with the layers that block everybody and the inspector says which — so the failure is always less X-Ray than you asked for, never an outline showing through something it should not.
Turning it off
The feature needs Keyline's occluder pass in the render pipeline, and it is off until you
ask for it — Project Settings ▸ Keyline ▸ Rendering Passes ▸ X-Ray Layer Filtering
puts it there or takes it away, see 16.4. Keyline never adds a pass
to your renderer unasked. Narrow a mask while it is off and the component says so on the spot, with
a button to switch it on: the moment you are watching for the setting to work is the moment to be
told it cannot. With the pass absent every mask is
ignored and X-Ray behaves exactly as it always has; no component is edited and no value is lost.
A missing pass never produces a picture you cannot explain.
In 2D, this is a second axis
A sprite scene has two layer systems and they answer different questions. Sorting layers decide who is an occluder — that is 12.4, and it stays a scene-wide decision. GameObject layers, this mask, decide which of those occluders a given outline can be seen through.
So a sprite can be an occluder and still let one particular character's X-Ray through, which sorting layers cannot express at all: a sorting layer is one number shared by everything drawing against it.
4 · The component
Keyline Outline 3D is the only component you have to add — Keyline
Outline 2D if the object is a sprite. Everything else in the package either sits on top of
one of them or is optional.
4.1 Outlines
No card is on the page until you add it. Main Outline, the overlays, the interior fills and X-Ray all stay off the inspector until you add them with Add pass — a freshly added component shows Setup and Tools and nothing else. A pass with no profile still draws nothing. Each pass is a card: colour chip, On in the header, profile slot in the body.
On toggles a pass without clearing its slot. The × in a card's header hides the row and turns that pass off; the profile stays assigned, so Add pass brings the same asset back.
| Row | What it holds |
|---|---|
| Main Outline | the rim profile (hull, unless that profile has Cutout on) |
| Main Overlay - Cutout Outline | second draw of Main, own stencil: hull (outer silhouette) or Cutout (alpha rim). Single Layer lives on this slot's profile. |
| Main Interior Fill | the fill drawn where the object is visible |
| X-Ray Outline | the rim drawn where the object is hidden |
| X-Ray Overlay - Cutout Outline | second draw of X-Ray, own stencil: hull or Cutout through walls. Single Layer lives on this slot's profile. |
| X-Ray Interior Fill | the fill drawn where the object is hidden |
Clip At Intersections — objects that pass through this one
A rim is kept off its own object by the stencil: a mask stamps the body's claim and the rim refuses to draw where that claim is. The mask stamps it only where the body is visible, which is all it needs to be until something passes through the object rather than in front of it.
On the line of an intersection the two surfaces share a depth. The covered part of the silhouette carries no claim, the depth test is a tie, and the rim wins it — so the outline draws a band around the intruder's cross-section, on a face that has no silhouette there. A pillar through a crate gets a ring of outline where it enters. Ordinary overlap never shows this; only intersection does.
Clip At Intersections, in the Main Outline card, claims the whole silhouette for the length of the outline's own draws and hands it back before anything else reads it, so the rim treats the covered part as its own body. It is on by default, including on objects authored before it existed.
It is on the component rather than on a profile because whether anything passes through an object is a fact about where that object stands, not about the look it is drawn with — two objects sharing one profile should not have to agree about it.
| Costs | Two stencil-only draws per source renderer on that object. |
|---|---|
| Turn it off when | nothing ever passes through the object and those draws matter — a crowd, or a platform counting every call. |
Where it does not reach. An intruder that carries an outline of its own has already claimed those pixels for its own rim, and a stencil cell holds one value, so the band can survive on it. The switch covers the visible outline only: an X-Ray rim draws where the object is hidden, so the silhouette it would be clipped against is the one it exists to show. The Cutout overlay keeps its own stencil pair and is not clipped either.
So on those profiles the clip is held on and the switch is replaced by a note saying which control is holding it. Your own value is not overwritten and comes back the moment the profile moves to Ignore Depth or Edge Softness 0.
4.2 Setup
In the order the card draws them, so the table can be read down the page beside the component.
| 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 — and works out, per vertex, how much further that averaged direction has to travel for the faces to still clear Width. Miter Limit caps the second part; turning this off loses both. Costs a one-time analysis when the outline is built. |
| Cutout Padding | auto | How far a Cutout rim may reach past the mesh's own edge. Auto works it out from the profile's Width and the source bounds; a manual value is for artwork whose alpha sits well inside the geometry. Read only when a profile on this object has Cutout on — see 8. |
| 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. |
| Auto Rebuild In Play Mode | off | Rebuilds when the renderers under this object change while the game is running — a weapon swapped, a limb detached. Off by default: in a build this is a standing cost paid to catch a case most projects never have. The Editor has a separate, project-wide switch for the same thing; the two never act at the same moment. See 16.4. |
| X-Ray Sees Through | everything | Which occluders this object's X-Ray may be seen through. See 3.5. |
| Merge Child Outlines | on | Children with their own Keyline component join this object's silhouette instead of drawing separately. See 9. |
| Merge When Overlapping | on | Two outlines whose rims meet draw as one silhouette. See 9. |
| 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.
- Add Draw Config — optional scene host for custom queues. Same item lives under
Tools ▸ Keyline. See 4.5.
4.4 Soft Edge (optional)
A 3D rim is hard by default. That is the product: hang the component on an object and you are done. Softness is a paid extra in setup, not a new default.
Edge Softness on a rim profile is a world-space halo, 0 to 8. One tick is about a centimetre. Zero is the old picture. The fade stays around the object as the camera moves — it does not spread in screen pixels. The value is read by Main Outline, X-Ray Outline, and their Overlays (Halo and Double extra layers travel with the rim). Interior Fill and X-Ray Interior never soften — they stay on the camera path.
A rim drawn from a distance field does not use this at all — neither a sprite nor a rim
with Cutout on. Those already carry a softness of their own,
Rim Softness, and it is the better one: the field knows the distance to the edge, so
the fade is resolved per pixel at the place the edge actually is, for the price of the fetch that
placed it. This pass exists for the opposite case — a hard hull silhouette, which has no gradient
anywhere in it to widen. The field is not offered both, and the slider is hidden rather than left
to do nothing.
Softness above zero does nothing until the Soft Edge pass is in the pipeline. The profile tells
you so beside the slider and offers a button to add it; the same switch project-wide is
Project Settings ▸ Keyline ▸ Rendering Passes ▸ Soft Edge. The rim stays hard rather
than failing silently.
- URP — a renderer feature on every Universal renderer the active pipeline uses, injected
after transparents (
AfterRenderingTransparents). Opaque depth is still there, so X-RayZTest Greaterstill works. After the blur, the pass punches this object's silhouette out of the buffer so the glow sits around the mesh, not on its albedo. Hard rims stay on the camera queue. - HDRP — a global Custom Pass volume (
BeforePostProcess), spawned hidden from the project setting in edit mode, in play and in a build. No scene holds it and none needs to.
When the pass is present and softness is above zero, those rim mirrors leave the camera's transparent queue and are drawn into an off-screen buffer, Kawase-blurred, then composited. The blur radius is converted from world centimetres at the object's depth, so the halo stays around the mesh instead of spreading as the camera pulls away. After the blur, the object's own mesh is punched out of that buffer (depth test against the camera copy) so the glow stays around the silhouette and does not paint the albedo. Objects in that buffer share its stencil and fence each other as they did on camera. Soft rims and hard rims (including every Interior Fill) do not share the camera stencil in the same frame — Single Layer on a soft object will not fence a hard neighbour, and the other way around.
Each profile uses its own world radius. Grouped objects share one isolation RT so their stencil still matches. One object's Edge Softness does not change another.
Glow Outside Only — keeping the soft X-Ray glow off the body
A hard rim is already fenced out of the object's own silhouette: the mask stamps the body's stencil claim and the rim tests against it. A blur has no stencil. Raise Edge Softness and the glow spreads inward as well as outward — over the very area an Interior Fill occupies, so the fill ends up with a glowing border painted on top of it.
Glow Outside Only sits in each rim's own card — Main Outline and X-Ray Outline — because the rim is what it changes. It answers one question for both: is the glow confined to outside the silhouette, or may it wash across the object?
The pass already removes the object's body from the blurred rim, so the glow does not land on its albedo — but only where the body wins the depth test. Hidden fragments are left glowing on purpose: that is the X-Ray look. From there the two families reach the same answer from opposite sides, which is why their defaults are opposite and both are what Keyline has always drawn.
| Rim | Default | On | Off |
|---|---|---|---|
| Main | On | Today's look: the glow stops at the silhouette. | Nothing is cut. The glow falls off inward over the object and over Main Interior Fill. |
| X-Ray | Off | The whole silhouette is cut, hidden fragments included. | Today's look: the glow stays on the hidden part of the body. |
A Main rim only exists where its object is visible, so the ordinary cut already confines its glow and switching off means cutting nothing at all. An X-Ray rim exists where its object is hidden, which the ordinary cut spares on purpose, so confining it takes the whole silhouette.
Turning Main off gives a Main rim the look an X-Ray rim has by default — a glow lying over the object. The rim's own colour still never lands there; stencil fences that, and only the blur reaches inward. So it reads as a falloff from the edge rather than as a fill.
It is on the component rather than on a style profile because it describes this object's silhouette, not a look: two objects sharing a profile should not be forced to agree about their own bodies, and a merge group shares one owner across several profiles. Each row appears only where it can act — while that card's own profile has Edge Softness above 0. With a hard rim there is no blur to keep out and the row is simply not there.
This is not the same control as Halo Occlusion below, and the two solve neighbouring problems. Glow Outside Only is about the glow lying over this object; Halo Occlusion is about the glow lying over the scene. They can be used together.
Halo Occlusion — whether the glow obeys the rim's own depth rule
The rim itself is depth-tested: a Main rim never appears behind a wall, an X-Ray rim never appears in front of one. The blur is not. It is a full-screen filter with no notion of depth, so it spreads the rim's colour straight across the line the depth test just drew. Both families get an artefact out of it, and they are mirror images:
- Main — an object half behind a wall casts its glow onto the wall's face, where the rim itself was correctly cut away.
- X-Ray — the glow of a rim seen through a wall smears past the wall's edge onto open space, where the rim itself correctly drew nothing.
Halo Occlusion, next to Edge Softness, decides whether that is what you want.
- Match Rim — the glow survives only where its rim would have been allowed to draw. Main where its object is in front of the scene, X-Ray where it is behind. The default, because a soft edge whose hard version is depth-tested and whose blur is not is not a style choice but a discrepancy.
- Ignore Depth — the glow draws wherever the blur reaches, which is what Keyline did before 1.2.0. Still the right answer when the glow is meant to read as light rather than as a marker: it keeps an object findable when most of it is hidden.
Match Rim also holds Clip At Intersections on for the Main rim of anything wearing the profile. The two remove the same band and the cut leaves a hairline the clip closes — see the note in 4.1. The component's own switch is left as you set it and returns under Ignore Depth.
It is one rule, not two features. The comparison is the same for both families and only its direction follows the pass, which is why the setting reads identically on either profile and needs no exception. An earlier version exempted X-Ray outright, on the reasoning that being visible through walls is what an X-Ray pass is for — true, and beside the point: honouring the rim's rule is exactly what keeps the X-Ray glow on the far side of the wall instead of spilling off it.
What it costs. A halo has no depth of its own once it has been blurred, so one has to be built: a screen-sized two-channel target, seeded from the object's own mesh and spread with the same filter footprint the blur uses. It is allocated only when something in view actually asks for it — which, Match Rim being the default, means as soon as any visible rim has Edge Softness above 0. A scene with no soft rim allocates nothing, and neither does one whose profiles are on Ignore Depth. Edge Softness itself starts at 0, so a project that never uses soft edges never meets this cost.
How far it reaches. The depth field is built from the object's mesh and spread as far as the blur reaches, so within that distance the test is exact. Further out — the band the rim's own width contributes, beyond anything the blur touched — there is no depth to test against, and the glow is left alone rather than guessed at. In practice that means a rim much wider than it is soft keeps an untested outer fringe: it may survive on a surface it would otherwise have been trimmed against. Raising Edge Softness, or narrowing Width, closes the gap.
Leaving it untested is deliberate. An earlier build filled that band with the object's centre depth instead, and the guess was wrong often enough to cut the fringe away — the outline appeared to eat its own edge at any softness too small to span the rim. Showing a little too much is a fault this feature is allowed to have; erasing what it was asked to trim is not.
4.5 Draw Config (queue and depth)
Queue, ZTest, ZWrite, Cull, depth bias and stencil Comp / Op / masks are hardcoded until you
add a scene host. Without KeylineDrawConfig in the scene, every 3D outline uses the
same factory numbers as the current working defaults — no ScriptableObject, no Resources asset.
The host is optional. Tools ▸ Keyline ▸ Add Draw Config creates a
Keyline Draw Config object if the scene does not already have one, writes
Assets/KeylineCustomDrawConfig.asset if that file is missing, and assigns it to
Custom Draw Config. Running the item again does not spawn a second object or a second
asset. There is no Create Asset menu for this type.
Reset All to factory on the assigned asset writes those working defaults back.
The inspector is three tabs:
- Queues — base queue, family offsets (Main / Overlay / X-Ray / X-Ray Overlay) and layer offsets (self-occlude, fill, mask, rim, gap, extra hull).
- Stages — per-draw ZTest, ZWrite, Cull, bias and stencil. Names are the same as the pass cards: Main Outline, X-Ray Outline (through walls), interiors, masks, overlays.
- X-Ray overlap — auto-sort nearer X-Ray families before farther ones, and the queue stride reserved for each object.
Greater, and its stencil WriteMask must stay None. The overlay family
must not overlap the hull’s queue block. The inspector warns; it does not block the edit. Stencil
which bit an object owns is still allocated by the pool — that is not in this asset.Soft Edge isolation (drawing X-Ray into a private RT) is not a Draw Config field. It stays a pipeline pass, see 4.4.
5 · The profile
A profile is an asset: Assets ▸ Create ▸ Keyline ▸ Style Profile, or the New
button next to any empty slot. Beside it in the same menu sits Mobile Solid Style
Profile — the same asset drawn by a lighter program, with no Mixed expand and no mesh
Cutout, and that absence is where the saving comes from. See 16.
Sixteen profiles ship in Runtime/Profiles/: one per style, plus
Profile_MobileSolid, the bundled Mobile Solid one. Tools ▸ Keyline ▸ Restore Bundled
Style Profiles puts them back if they are edited by accident — rewriting the ones still
there, and recreating any that have been deleted.
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 fifteen 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, Lava, Smoke, Crystal, Water, Fire. 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, and a whole number of source texels in Pixels mode. The field's label and units follow the mode, and in Pixels it becomes an integer with a step button either side. |
| Edge Softness | World-space halo on a 3D rim (0–8). One tick is about a centimetre. Stays around the object as the camera moves — it does not spread in screen pixels. Zero is a hard edge. Main, X-Ray and their Overlays read it; Interior Fill ignores it; a rim drawn from a distance field does not show it at all — sprites and Cutout soften through Rim Softness instead. Needs the Soft Edge pass — 4.4. Each profile uses its own value. |
| Halo Occlusion | Whether the halo obeys the same depth rule its hard rim obeys. Match Rim, the
default, keeps the glow behind whatever hides the rim, so a soft Main outline stops at an
occluder and a soft X-Ray outline is the mirror of that. IgnoreDepth lets the
glow lie over the scene — the pre-1.2 look, and still the right one for a lamp or an aura
that should wash over what is in front of it. Shown only while this profile's Edge Softness
is above 0. Not the same control as Glow Outside Only, which is about the object's
own body. See 4.4. |
| 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. Pixels counts texels of the sprite's own
texture and is for 2D pixel art — see 12.3. |
| Opacity | Master fade for the whole pass, 0 to 1. This is the one to animate. |
| Single Layer | Paint each pixel once where this pass's hull covers it twice. Each pass reads its own profile — X-Ray Outline and X-Ray Interior honour the flag the same way Main does. Turning X-Ray on does not fence Main Outline. Hidden for Halo, Frost and Double, whose overlap is the look. See 5.6. |
| Face Cull | Which faces draw this pass. Rim passes: Both draws both walls of the hull. A hull has a near wall and a far wall, and
Interior fills honour it too. Width is 0 on a fill, so the geometry is the model's
own surface and nothing is extruded — the setting only picks which faces of it draw.
|
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.
Ignore Object Stretch, directly under it and only while the space is Object,
decides whether a non-uniform scale is allowed to stretch the pattern. Object space is the model's
own local position, so scaling a transform (2, 1, 1) leaves that coordinate alone while
the mesh becomes twice as wide — noise cells and flow axes then come out elliptical. Smoke and Fire
show it most, because their flow axis turns the skew into a lean. On by default; turn it off for the
pre-1.2 behaviour, where the style stretches along with the mesh.
A uniformly scaled object is untouched either way, and that is arithmetic rather than a promise: the correction is normalised so it comes out exactly 1 there. Only an object that was already being stretched can look different. The other three spaces do not measure in local units, so there is no stretch in them to remove and the row is not shown.
| 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 |
A new profile starts in Object Space whatever its style, and Reset puts it back there. That is the answer that needs no explaining: the effect sits on the object and travels with it. Anything else is a deliberate choice, and a deliberate choice belongs to the person making it rather than to a default. Profiles authored before Style Space existed keep the space they were saved with, which is the one case where reproducing the old look is the whole point.
Two styles offer three of the four. Pattern and Gradient both measure along a single axis from the object's pivot, which on an unrotated object is the same number in world space as in object space — a mode that duplicates its neighbour most of the time is worse than one fewer mode. So World is not offered for them, and a profile carrying it is moved back to Object.
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 default 1.45 |
How far a vertex on a hard edge may be pushed beyond the plain offset, so that edge keeps
the same outline thickness a curved surface gets. This is what makes a box and a sphere
match on one profile — see 7.1 for why they otherwise do
not. The default clears the 1.41 a right-angled edge asks for, so square edges and cylinder
rims reach the full Width, while a three-face corner is held a little short and eases in
rather than spiking out. Raise it towards 2 for a perfectly square corner; higher only
matters on acute spikes, where the exact correction runs away. 1 means no correction at
all. Needs Smooth Normals, and applies under Normal and
Mixed. |
| 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.
5.5 Blend Mode
How the pass is composited over what is behind it — a rim, an interior fill, either of the X-Ray pair. It is a property of the profile, so each pass can be laid over the frame differently. In 1.0.0 the style decided this by itself and there was no field; Default is that behaviour kept intact, so every profile written before this existed looks the same as it did.
The mode matters most where there is something underneath for it to act on, which is why the picture below is a fill: an interior fill covers the model's own lit surface, and what a mode does to that surface is the whole of what separates the modes from one another.
| Mode | What it does |
|---|---|
| Default | The style decides. Neon, Halo, Electric and Rainbow add their colour to the frame; every other style blends with transparency. |
| Alpha | Ordinary transparency, and the only mode where Opacity covers what is behind it. Pick this to make a light style solid. |
| Additive | Light. Bright over dark; over a pale background it clips to white and the shape goes with it. |
| Screen | Light that cannot blow out — approaches white instead of hitting it, so a glow keeps its shape over bright scenery. Usually the safer of the two glows. |
| Multiply | Ink. Darkens and tints rather than covering: a contour, a coloured shadow, a stamped line. Opacity reads as how far towards the colour to go. |
| Subtract | The colour taken out of the frame. A hole cut in the light. |
| Darken / Lighten | Whichever is darker or brighter, channel by channel. Lighten is the glow that stops climbing however many outlines overlap. |
5.6 Single Layer
A hull covers some pixels twice — wherever the shape folds back on itself, and at every seam of an object built from several meshes. At full opacity that is invisible. Drop Opacity to about a half and the doubled places show as bright patches, because two half-transparent layers are not one half-transparent layer.
Single Layer paints each pixel once instead. Once per unit: the whole object with all its renderers, or the whole merge group. Two separate objects still draw over each other — that is what merging is for.
It is 3D only. A sprite outline is cut from a distance field on a quad, and a quad does not cover any pixel twice, so there is nothing to fence; the 2D inspector says so if a profile carrying the flag is assigned there.
Not available on Halo, Frost or Double. Those styles are built from stacked hulls whose overlap is the falloff — fencing them would leave one layer standing. The field is hidden for them, and the check is repeated in the code, because a profile outlives the style it was set up under.
6 · Styles
Fifteen 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. Twelve of the fifteen 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, Lava, Smoke, Crystal, Water, Fire | 1 | one band, drawn once |
| Frost | 2 | a soft shell around the core |
| Double | 3 on a mesh, 1 on a sprite | inner ring, outer ring, and a mask that keeps the gap empty — or all three cut from one distance |
| Halo | 2–8 on a mesh, 1 on a sprite | one per layer — or the whole falloff in a single draw, cut from a distance field |
All fifteen 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 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, and it is the only control the style has. The ramp between them is a straight line from the object out to nothing, and it is not adjustable — it had an outer alpha and a fade shape once, and both were taken away. The layers carry the differences between levels of the curve, so whatever those two were set to, the sum came back to almost the same value at the inner edge: two sliders that between them reached one thin band are worse than no sliders at all. Width and Layers say everything this style needs told.
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 on screen, or following the UVs. World is not offered here — measured from the pivot along one axis, it would repeat what Object already does.
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. The profile's shared Width is not read by this style and is hidden for it — these three are the whole of the ring.
On a mesh this is three draws per object: the two rings, plus a mask that keeps the gap genuinely empty rather than filled by whatever is behind. A hull knows only its own radius, so the core and the ring cannot be told apart within one.
On a sprite it is one draw. Both boundaries are thresholds on the same distance, and the gap is simply where neither ring is — nothing is drawn there, so nothing has to be masked out.
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. It shares Amount and Speed with Sketch, but the two do not mean the same thing here: Electric stacks three noise terms where Sketch uses one, so the shipped profile asks for less Amount — 0.5 against Sketch's 1.2 — and far more Speed, 2 against 0.35.
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, Halo Width and Halo Opacity size it — the labels say halo because that is what the outer mirror reads as — and Sparkle is the noise. Band Fade is how large a patch of plain ice is left where the surface turns to face the camera; it is worth leaving alone, because the strata are keyed to a direction that stops meaning anything head-on, and at zero they spin into a cross there. Uses the secondary colour.
Two hulls per object — the core and the shell.
6.11 Lava
A dark crust broken open on a moving core. Cracks is how much of the crust has split — at zero it is unbroken and the style is a dark rim, at one there is no crust left and the whole band is molten. Heat is the brightness inside the cracks over the outline's colour; past one it goes white-hot, which is what sells it as emitting rather than merely being orange.
Flow and Flow Direction move the crust, Crust Scale sizes it, and Turbulence decides how much the flow folds rather than slides. Pulse Rate and Pulse Depth breathe the heat. Uses the secondary colour for the crust.
6.12 Smoke
A billowing cloud rather than a line. Density is how much of the band is filled, Billow Scale sizes the puffs, Drift, Drift Direction and Drift Tilt carry them — the tilt leans the drift out of the style space's XY plane, which is the only plane a single angle can point in — and Turbulence is how much the drift curls instead of sliding.
Height is how far along the drift the cloud lasts, as a fraction of the shape — the same control Fire calls Height, and it reads the same way. At 0.3 the cloud clings to where it started and is gone a third of the way along; at 1 it just reaches the far edge; past 1 it leaves the shape still thick, which is what smoke coming off a thing rather than sitting on it looks like. It is subtracted from the cloud rather than clipping it, so the smoke ends by having too little substance left to show — which is what stops it reading as a painted shape with an edge.
Fray is the manner of that ending, once Height has decided where. Low keeps what survives opaque and gives it a defined edge, closer to steam under pressure; high stretches the last of it into wisps that fade out over a distance. Uses the secondary colour.
6.13 Crystal
A cut stone: the band is partitioned into facets, each one a flat plane catching the light on its own. Facet Scale is how large they are, Evenness how regular — low gives a rough rock, high a machine-cut gem. Cut Edges is the brightness of the lines between facets, and it is what makes the shape read as cut rather than as painted.
Glint is how much fire the stone has. Zero is a stone with none — not a dim one, none — and upward the highlight widens and brightens, so more facets catch the light at once. Low leaves one or two glinting while the rest stay dark, which is what reads as cut; high lights most of the stone, which reads as polished. Glint White is the ceiling on how far a lit facet washes out — at zero it keeps its own colour, which suits a coloured gem; at one it blows out like cut glass. Dispersion splits the colour between neighbouring facets, small amounts reading as a precious stone and large as a prism. Light Direction and Spin move the light; Facet Depth shades each facet by distance from its own centre, which trades flat panes for pebbles. Uses the secondary colour for the fringe the facets are split towards.
6.14 Water
A surface flowing around the object. Flow and Flow Direction carry it, Ripple Scale sizes the ripples, Turbulence folds the flow rather than sliding it.
Caustics is the bright lacing where the surface focuses light, and Sheen is the gloss along the crests where the surface turns towards the viewer. The two share one blend towards the crest colour, so together they reach it and stop rather than adding up into white. Uses the secondary colour.
6.15 Fire
Tongues rising off the silhouette. Rise and Rise Direction are the speed and the way up. Height is how far the tongues reach as a fraction of the shape: at 0.3 the fire clings to the base and dies a third of the way along, at 1 it just reaches the far edge, and past 1 the tips run off the edge instead of dying inside it — which is what a fire that is over the thing rather than on it looks like.
Stretch is how many times longer a tongue is than it is wide. At 1 they are round — blobs of heat rather than flame — and raising it squashes the pattern along the rise direction, which is the whole difference between noise and fire. Tongue Scale, Rise Tilt, Turbulence, Heat and Flicker shape the rest — the tilt leans the climb out of the style space's XY plane, which is the only plane Direction alone can point in. Uses the secondary colour for the cooler tips.
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.
The quieter failure: hard edges come out thinner
The same mechanism has a second consequence, and this one shows up on models that are perfectly
solid. A vertex where several faces meet has one normal, averaged between them. Push along it
by Width and none of those faces actually travels Width — each moves out by
Width × cos θ, where θ is the angle between the shared normal and that face.
On a smooth surface θ is nil and the outline is exactly as wide as asked. On a hard edge it is not:
| Shape | θ at the silhouette | Outline you actually get |
|---|---|---|
| Sphere, capsule | ≈ 0° | 1.00 × Width |
| Cylinder — the rim between wall and lid | 45° | 0.71 × Width |
| Cube — corner normal (1,1,1)/√3 | 54.7° | 0.58 × Width |
So one profile on four primitives draws four different thicknesses, with the box at little more than half the sphere. Nothing is misconfigured; it is what pushing along an averaged direction does.
Smooth Normals measures the correction each vertex needs — 1 / cos θ — and
Miter Limit caps it, because on an acute spike that figure runs away and the point would
shoot out into a needle. At 1 there is no correction at all, which is the behaviour in the
table above.
The default, 1.45, sits just above the 1.41 a right-angled edge asks for.
That is a deliberate line to draw: an edge is corrected exactly, and a point is not.
| Feature | Correction it wants | At Miter Limit 1.45 |
|---|---|---|
| Sphere, capsule, any smooth surface | 1.00 | 1.00 × Width |
| Cube edge, cylinder rim — two faces at 90° | 1.41 | 1.00 × Width |
| Cube corner — three faces at 90° | 1.73 | 0.84 × Width |
So the long edges that make up almost all of a box's silhouette come out at exactly the Width asked for, and only the corner points themselves stay slightly tucked in — which reads as the outline easing round the corner rather than shooting past it. Raise it to 2 if you want the corner geometrically exact as well.
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 |
|---|---|
| Mixed Detect Mode | Thickness casts one ray per face — cheaper, coarser.
Multi Ray 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 |
|---|---|
| Sheet Solidify | On out of the box. Off leaves Mixed's sheet detection and in-plane expand doing their work unassisted. |
| Separate | Also on out of the box. On, the shell is sized by Thickness and Lateral; off, by the single Volume number. The inspector shows one pair or the other, never both — they are two ways of saying the same thing. |
| 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 | The one number that drives both, when Separate is off. |
| 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 ▸ 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.
There is no threshold to set on the profile. The outline is cut where the material is cut —
_AlphaCutoff on an HDRP Lit material, _Cutoff on anything else — and the
distance field is built at that same level. There used to be a second number here and it was
removed rather than kept: the field records where the alpha crosses a level and the material cuts
the artwork at its own, so the two have to agree, and two numbers that must be equal is one number
too many. Move the material's cutoff and the outline follows — the field notices and is rebuilt at
the new level.
8.2 The distance field
A hull widens edges the mesh has, and a punched contour is not one of them: no vertex sits on it. So the rim has to be drawn by widening the alpha itself — and an alpha cannot be widened well.
It is very nearly binary: a texel is opaque or it is not. Sampling it in a ring around each pixel and keeping the pixel if anything solid was found does widen the shape, but every sample answers yes or no and none of them answers how far. The boundary cannot be resolved below one texel, so the edge steps. Worse, a ring of N samples is an N-gon — the widened shape has as many flat sides as there were samples — so the count has to climb with the rim's size on screen, making a wide rim both the most expensive case and the ugliest.
Keyline builds a signed distance field from that same alpha instead, once, at author time. The field stores what the alpha does not: how far each texel is from the edge, to a fraction of a texel. Drawing then costs one texture fetch per pixel at any width, the edge is smooth at any zoom, and corners come out round because the distance to a corner really is round.
The field also carries the whole profile of the rim to every pixel, which is what lets Halo compute its falloff as a curve and Double cut its gap directly, instead of stacking one draw per concentric layer.
Where the fields come from
Nobody has to make them by hand.
- Cutout meshes: a Generate Distance Field button under Tools on the component. Keyline finds the textures the object's materials draw with, builds a field beside each, and stores the references on the component. It is a button rather than something that happens on selection because building writes files and imports them, and neither should follow from clicking on an object. It reads Rebuild Distance Field once the fields exist — press it when the artwork changes, because nothing notices on its own and a stale field traces the shape the texture used to be.
- Sprites: a Generate button on Keyline Outline 2D. A field already sitting beside the sprite is picked up automatically.
The reference matters as much as the file. A build includes what something points at, and nothing else points at these — a cutout mesh's texture is found by reading its material at run time, which a build has no way to anticipate. That is why the field is stored on the component rather than looked up by name.
Range
Range is how far the field can describe, in texels, and it is a hard ceiling on rim width: past it the field only says "at least this far", which cannot place an edge, so the outline stops there. Reach is paid for in memory, not precision: the field is padded by its range on every side, so a 512×512 texture at Range 32 is written as 576×576. Sixteen bits of distance put a step far below anything a rim can show, whatever the range — see 12.2. Pick the smallest that covers your widest outline.
Rim Softness is how far the edge fades out, as a fraction of the rim's own width. 0 is as crisp as an edge can honestly be — one screen pixel, which is what antialiasing needs and no more — and 1 fades across the whole band, which reads as a halo rather than a drawn line. A fraction rather than a pixel count so that one setting looks the same on a two-pixel rim and a forty-pixel one.
It is also part of how far the rim reaches: the band fades to nothing across the feather, so the last visible thing sits about half a feather past the width. Set To Max on Width and the minimum Padding both allow for it, which is why raising Softness lowers the width the two of them suggest.
Under Width Mode: Pixels the same slider is drawn as Rim Falloff. It is still a fraction of the rim's own width — 0.5 fades the outer half of its texels and leaves the inner half solid — but the fade steps by whole texels, because that is the only unit a pixel rim has: the outermost ring is dimmest and each one inward is brighter. See 12.3.
Layers
Layers is how many steps the falloff is built from, and it means the same thing on both subjects — which is the point. A mesh draws that many concentric hulls, and because they blend additively the brightness at any radius is the level of the nearest hull outside it: a five-layer halo really is five bands, not a ramp. A sprite quantises its distance the same way and gets the same five bands out of one draw.
What differs is the price. On a mesh every layer is a draw call, so the count stops at 8. On a sprite it costs nothing, so it goes to 32 — which is smooth enough to stop looking like steps at all.
8.3 What Cutout changes elsewhere
Two more consequences worth knowing before shipping it:
- Cutout on a mesh is a Full-profile feature. A Mobile Solid mesh has no Cutout control and its material never takes the cutout branch, which is part of what makes it light. A sprite is the exception rather than a limitation: every sprite rim is a distance field rather than a hull, so Mobile Solid draws it from the same shared code Full uses, and Rim Softness and Rim Inset apply there exactly as they do on a Full profile.
- 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, a sprite texture on a quad. Single- or double-sided makes no difference; flatness does.
SpriteRenderer. Cutout reads the alpha of the material on a MeshRenderer, so a
quad wearing a sprite's texture works exactly like a foliage card. A
SpriteRenderer is a different renderer type and has its own component,
Keyline Outline 2D — it measures the rim from the artwork's alpha instead of extruding a
hull. Put Keyline Outline 3D on a sprite and the Inspector offers a button to swap it over. See
Sprites.On a solid mesh the alpha rim sits on the surface; the outer silhouette is a normal hull outline, or a Cutout Overlay on the same pass. Padding that widens a flat card in-plane is skipped on volumes — Cutout still traces alpha.
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 On the outline itself
Two switches under Setup on both outline components, on out of the box:
| Setting | What it joins |
|---|---|
| Merge Child Outlines | The children, drawn inside this silhouette rather than beside it. It applies to any child that has an outline — one of its own, or one generated for it by Include Children. It is a statement about the children: whether this outline joins the one above it is that one's decision, taken with its own copy of the switch. |
| Merge When Overlapping | Whatever this outline's bounds touch. Tested between figures, not between objects: a model that has taken its children into one silhouette merges on contact as a whole, so this switch covers those children too and something touching a child merges with the model rather than with the part it brushed. A figure merges when any part of it is willing, so two separate objects both need it on. The outline counts, not only the object — see the note below. Re-tested a few times a second, not every frame. |
Both work identically on Keyline Outline 3D and Keyline Outline 2D.
Overlap on a sprite is measured from the renderer's bounds, which is a rectangle: two sprites whose
artwork does not touch can still have overlapping bounds.
A Width Mode of Constant Screen is the exception: its width is a different
distance in the world at every camera position, so counting it would make merging a property of
where the camera is standing — silhouettes fusing and splitting as somebody walks towards
them. Those bodies still merge on overlap; only the rim's own reach is left out. World
on a model, and World or Pixels on a sprite, are all counted.
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 and, with Group Children on as it ships, every outlined object beneath it belongs to the group. Membership is then the hierarchy — reparent a member in or out at runtime and its controller notices by itself, with nothing to be told. The group's stencil pair is allocated and released by enabling and disabling the component.
Two lists qualify that. Members names outlines one at a time, which is how a group gathers objects that are not under it; naming any member at all is also what turns the group into a list rather than a subtree, so unnamed objects underneath stop being swept in. Ignored does the opposite for one child: it keeps its own outline instead of joining the silhouette.
9.3 How it is decided
Not "which rule wins" — all of them apply at once. Belonging to a silhouette is transitive: if A is with B and B is with C, then A is with C. Each rule adds a link, the links combine, and every outline in one resulting set is drawn as one silhouette.
| Rule | Links |
|---|---|
| Merge Group | every member of one active group |
| Merge Child Outlines | an outline and the one above it, when that one takes its children |
| Merge When Overlapping | two outlines whose bounds touch, when both are willing |
Because links combine rather than compete, the order they are found in does not matter — which is what makes the result predictable. A model inside a group brings its children in with it: the child is linked to its parent, the parent to the group, so all of them are one silhouette and all of them wear the group's stencil.
Overlap is vetoed where somebody has already spoken. A parent that does not take its children walls that branch off, and a group that names its members by list keeps two unnamed objects under it apart. A Merge Group and a parent are statements a person made; overlap is a guess from geometry, and a guess does not overrule a person. The ignore list takes an object out of all three.
Log Pass Diagnostics on an outline and
it prints the silhouette it resolved to. Two outlines showing the same value are one silhouette; two
showing different values are not.None of the merge settings can repair that, because it happens below them. Give the sprites different Order In Layer values, or put them on different Sorting Layers. Their Sorting Order Offset counts too: two sprites on the same order whose offsets differ do not collide, because the offset is what moves each rim off its own sprite.
Duplicating a sprite is the usual way into this — a copy inherits both numbers exactly. The component and the Merge Group both say so in the Inspector when they see it.
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 126-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 ▸ Recipes, 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 |
KeylineXRayWhenHidden | through-walls only while actually hidden. Was KeylineOccludedXRay, which still compiles as a deprecated subclass |
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 What every timed recipe shares
Five of them run for a while and then stop: Blink, Damage Flash, Impact Wave, Scan Wave and Reveal. They answer to the same three members and raise the same two events, so code that drives several of them does not have to remember which is which.
| Member | |
|---|---|
Play(...) | starts it. Every recipe also keeps the name that describes what it actually does — Emit for a wave, Ping for a scan, Flash for a hit. Neither name is preferred: Play exists because it is the first thing most people type, the specific name because it reads better in your own code |
Stop() | ends it early and puts the slot back. Calling it when nothing is running does nothing |
IsPlaying | true while it runs. Damage Flash also answers to IsFlashing, which is the same value |
Started | UnityEvent, fires on every start — including one that restarts an effect already running |
Finished | UnityEvent, fires when it ends by any route: ran its course, Stop, or the object being disabled. It does not fire for a Stop that had nothing to stop |
Both events are on the component in the Inspector, so a designer can wire the next step — a sound, a spawn, another recipe — without writing anything.
Stop() means
finish, not cancel: a reveal aborted halfway would leave the object half
materialised, which is a state nobody asked for. It completes and raises Revealed
exactly as it would have on its own. Finished always fires after
Revealed or Dissolved, never instead of them.11.2 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.3 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.4 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.5 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.Flash(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.
| API | |
|---|---|
Flash() · Play() | ordinary hit at full strength |
Flash(float strength) · Play(float) | pass damage over max health and a scratch reads differently from a heavy hit for free |
Flash(Vector3 worldPoint, float strength = 1f) · Play(Vector3, float) | directional — brightest where it landed |
FlashCritical() · FlashCritical(Vector3) | the heavier colour and glow. Its own name because it is a different effect, not a different signature |
Stop() | cut the flash and restore |
IsFlashing · IsPlaying | the same value under both names |
Directional, Falloff, FalloffSoftness | shape of a directional hit, at runtime |
Started, Finished | UnityEvents |
Default pass: Main Interior — the fill is what reads as the body being hit, and it leaves your rim profile alone.
11.6 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.
| API | |
|---|---|
Value | the target level, 0 to 1. The drawn level eases towards it |
SetValue(float) · SetValue(float current, float max) | the same thing as a method, for UnityEvents and for raw health numbers |
SnapTo(float) | set and draw immediately, no easing — for spawning at full health |
DisplayedValue | what is actually on screen this frame, which lags Value by Smoothing |
Angle | direction the gauge fills, 0 to 360. 0 fills bottom to top |
FillColor, EmptyColor, EdgeSoftness | look, at runtime |
TintWhenLow, LowColor, LowThreshold | the warning shift |
OffsetAtEmpty, OffsetAtFull, CurrentOffset | calibration, described above |
Default pass: Main Interior. This is the one recipe that holds its pass continuously rather than for the length of an effect, which is why it is the one most likely to be disturbed by another recipe sharing the pass.
11.7 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 sphere growing from a point, or 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.
Leave its target list empty and it collects every controller in the scene the first time it
runs. InvalidateTargets() makes it collect again — call it after spawning.
| Impact Wave | |
|---|---|
Emit(Vector3 worldPoint) · Play(Vector3) | from a point — usually where the hit landed |
Emit(RaycastHit) · Play(RaycastHit) | straight from a raycast |
Emit() · Play() | from the object's own centre |
Stop(), IsPlaying | as in 11.1 |
Speed, Reach, Thickness, EdgeSoftness | the front, in metres and metres per second |
FadeIn, Hold, FadeOut | the envelope. Total life is the three added up |
CoreColor, EdgeColor, Glow | look, at runtime |
| Scan Wave | |
|---|---|
Ping(Vector3 origin) · Play(Vector3) | send a front out from a point in world space |
Ping() · Play() | from this object's own position |
Stop() | ends it and puts every object it touched back as it was |
Distance | how far the front has travelled, in metres |
Shape, Axis | sphere or plane, and which way a plane faces |
Speed, Range, Thickness, EdgeSoftness | the front, in metres |
InvalidateTargets() | rebuild the target list from the scene on the next ping |
Default pass for both: Main Interior.
if (Physics.Raycast(ray, out RaycastHit hit))
{
var wave = hit.collider.GetComponentInParent<KeylineImpactWave>();
if (wave != null) wave.Emit(hit); // or wave.Play(hit)
}
11.8 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.
| API | |
|---|---|
Play() | materialise |
PlayReverse() | dissolve. Its own name because it is the opposite behaviour, not another signature |
Complete() · Stop() | end now, with the object present. See the note in 11.1 |
IsPlaying | true during either direction |
Revealed, Dissolved | UnityEvents, one per direction |
Started, Finished | UnityEvents, either direction |
ClimbSeconds, FlareSeconds, FlareGlow | timing and the flare |
FillColor, EdgeSoftness | look of the climbing fill |
OffsetAtEmpty, OffsetAtFull | calibration, same idea as Fill Gauge |
Default pass: Main Interior. Play On Enable is on by default, so a prefab dropped into the scene materialises by itself; turn it off if you want to trigger it yourself.
Start, not in Awake, so that code which
adds the component and then configures it is not racing a run that already began. Spawn the prefab
inactive if a single frame of visible mesh matters.11.9 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.10 X-Ray When Hidden
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.
This was KeylineOccludedXRay in 1.0.0. Updating breaks nothing:
components already in scenes and prefabs keep working, and the old name still compiles — it lives
on as a subclass of this one, so it is this component under either name rather than a
wrapper around it. The compiler marks each use as obsolete so you can rename at your own pace. New
objects should be given X-Ray When Hidden; the old name is hidden from Add Component.
11.11 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
| API | |
|---|---|
Play() | start with the count set in the Inspector. 0 means until stopped |
Play(int count) | start with a count given here, whatever the Inspector says |
Stop() | stop and release the state |
IsPlaying, StateId | state of the run, and which highlight state it holds |
Started, Finished | UnityEvents |
Unlike the other four, Blink drives the arbiter rather than a
pass of its own — it pushes and pops a named state. That is what lets a blinking object stay
selected underneath, and it is why Blink needs a KeylineHighlight on the same object
while the others need a KeylineOutlineController.
Hover, most often. The pointer leaving the object pops that state and
cancels the blink. Give the cue a state of its own, and give it a priority above the states it has
to show through.12 · Sprites
A SpriteRenderer has its own component, Keyline Outline 2D. Everything about
the look is shared with meshes — the same profile assets, the same styles, the same four passes —
but the outline is made a different way, and that difference decides most of what follows.
A mesh outline is an inverted hull: the geometry is pushed outwards and the silhouette does the rest. A sprite has no hull to push. Instead the rim is measured against a distance field built from the artwork's alpha, so the outline follows what is drawn rather than the quad it is drawn on — a hole in the middle of a sprite gets an outline, and so does every notch along its edge.
12.1 The component
Add Keyline Outline 2D to the object, press Add pass, and assign a profile to Main Outline. Extra passes — Main Interior Fill, X-Ray Outline, X-Ray Interior Fill — are added the same way as on the mesh component, and the × on a card hides the row and turns the pass off while leaving the profile assigned. If you added the mesh component by mistake, its Inspector offers a button to swap it over.
The pass rows, the profile slots and everything under them are the mesh component's, unchanged; what follows is only what a sprite has instead of a hull.
| Field | Default | What it does |
|---|---|---|
| Padding | 0.25 | Empty room around the sprite for the rim to be drawn on, in the sprite's own units. The rim can only appear where the quad has surface, so too small a value cuts the outline off in a straight line wherever it would have gone further — which reads as a corrupted outline rather than as a number being too small. Set Minimum beside the field works out exactly enough for the widest pass this object draws. |
| Sorting Order Offset | 1 | How many sorting orders away from the sprite the passes are placed. The main outline is drawn under the sprite as a solid silhouette, so the artwork covers its middle and no seam can appear along an antialiased edge. Two overlapping sprites that share a Sorting Layer, an Order In Layer and this offset cannot merge — see 9.3. Give one of them a different order. |
| Field | found | The distance field the rim is measured against. Filled in automatically when one is sitting beside the sprite; built with the button below when there is not. It is a serialized reference rather than a name looked up at run time, because that reference is what pulls the texture into a player build. |
| Field Mode | Smooth | What the field is measured with. Smooth for painted and vector artwork, Pixel Art for artwork meant to be seen texel by texel. Changing it needs the field rebuilt — the rounding is in the numbers, not in how they are read. See 12.3. |
| Range (texels) | 32 | How far the next field can describe, and a hard ceiling on rim width: past it the field only says "at least this far", which cannot place an edge, so the outline stops growing while Width goes on rising. Reach costs memory, not precision or speed — the field is padded by its range on every side, so a 32×32 sprite at Range 32 is written as 96×96. |
| Include Children | off | Outlines every sprite under this transform, so a multi-part character reads as one object. Off out of the box, unlike the mesh component: a 2D figure is usually several sprites drawn in a deliberate order, and quietly taking them all over is a larger decision than it looks. Ignore Children List is the exceptions. |
| Instance Override | on | Runtime writes fork the profile into a copy this object owns rather than editing the shared asset — the same rule as on meshes. See 3.3. |
The Inspector reports what it knows rather than leaving it to be discovered: the range and metric the assigned field actually holds, the resolved rim width in texels, and a warning with a fix button for each of the handful of settings that quietly undo an outline.
12.2 The distance field
Under Distance Field, press Generate Distance Field. Nothing is built automatically: the file is written next to the sprite's texture and named after it, and writing into somebody's project is a decision rather than a side effect of selecting an object. One field is built per texture and shared by every sprite using it, so a sheet holding sixty frames is one file.
The range, the threshold, the metric, the precision and the scale it was measured under all travel in the file's name. A field is a table of distances scaled by one number, and read back with a different one every distance in it is wrong by that ratio — while the picture still looks perfectly well-formed. Nothing about the pixels reveals the mistake, so the numbers live where they cannot drift away from the file.
Padding
Every field is baked on a canvas larger than the artwork, padded on each side by the range. The rim is drawn outside the sprite by definition, and a field that stopped where the artwork stops has nothing to say about the ground the rim is standing on. Where the drawing runs to the edge of its own texture there is no honest answer to extrapolate — the distance travelled past the border is added as a radius, so a wide outline grows a round bulge along what is a perfectly straight side in the artwork.
So a 32×32 sprite at Range 32 produces a 96×96 file. Lower the Range before generating if that matters; an outline of one or two texels does not need thirty-two.
Power Of Two beside the range grows the canvas to the next power of two on each axis. It makes the file larger, not smaller — a source already at a power of two crosses to the next one as soon as any margin is added, so 128 becomes 256 where the plain padding would have stopped at 192 — and it is off by default for that reason. Tick it where something downstream requires the guarantee. Block compression is not that something: it needs sides that are multiples of four, which these already are, and it would ruin a distance field anyway, since it works by discarding exactly the small local differences the field is made of.
Precision
Fields are written with sixteen bits of distance, split across two channels — the high byte in red, the low byte in alpha. One byte gives 256 steps across twice the range, so at Range 32 a step is a quarter of a texel and at 128 it is a whole one; a whole texel of error is the size of the detail the field exists to resolve, and it shows on a wide smooth rim as faint terracing. The second byte removes it.
The low byte swings across its whole range every quarter texel, which is why it is in alpha rather than in a colour channel: in green it turned the file's thumbnail into static, and a texture that looks corrupted is one that gets reported as corrupted. Fields written before this carry no marker in their name and are read back as the eight-bit fields they are.
Stretched sprites
A sprite scaled (2, 1, 1) has texels twice as wide as they are tall in the world. A
field measured on the texel grid knows nothing about that, so a rim of three texels is three texels
in both directions — which is twice as thick down the sides as it is across the top. Nothing
downstream can undo it: the shader reads one number per texel, and the direction that number was
measured in is exactly what a distance field throws away.
So a smooth field is measured on the sprite's own stretched grid instead, automatically, and the rim comes out even. Width keeps its meaning — the arithmetic is normalised so that a Width of W is still a world radius of W, rather than the rim becoming thicker or thinner as a side effect of being evened out.
Only the ratio of X to Y matters. A sprite at (3, 3, 1) has square texels
exactly as one at (1, 1, 1) does and needs nothing done to it. The Inspector says which
ratio the next build will be measured for, and warns when a field's ratio and its sprite's have
drifted apart — change the scale after baking and the field has to be rebuilt, which it says rather
than leaving a rim that is subtly uneven.
Animation
An animation whose frames all live on one sheet needs nothing extra. One whose frames are spread across several textures needs a field per texture, and the Animation section takes the clip and builds them all: its sprite keyframes name every texture the animation will ever show, so the set is a read rather than a search. The outline swaps fields as the frames change, so the rim is always measured against the picture actually on screen.
Sprite Atlas
Supported for Full Rect packing without rotation. The sprite draws from the packed page while the field still covers the source sheet, and the mapping between the two is measured from the sprite's own rectangle. Tight packing or rotation leaves no rectangle to map through: the rim falls back to the silhouette and says so once in the console. An animation that jumps between source sheets swaps fields with the frame, packed or not, as long as each sheet has an entry in Fields — Generate on the clip records them.
12.3 Pixel art
A smooth field measures real distances and resolves its edge below a texel. That is the right answer for painted and vector artwork and the wrong one for pixel art, where the boundary between two texels is the artwork and rounding a corner is a mistake rather than a nicety.
Set Field Mode to Pixel Art and rebuild. The field is then measured on the texel grid, so the edge lands on texel boundaries and corners come out square — an outline drawn the way the artist would have drawn it by hand.
| Corners | A diagonal step costs | Reads as |
|---|---|---|
| 8-way | one | the outline follows staircase edges and closes around single-texel corners. What most pixel art wants, and the default. |
| 4-way | two | no outline across a diagonal join and outer corners cut back by one texel — a thinner contour, useful on small sprites where 8-way reads as heavy. |
Both are baked to their own file, so a sprite can carry one of each and the Corners switch moves between them without rebuilding anything.
Width is counted in texels
Set the profile's Width Mode to Pixels. Width is then a whole number of source texels: 1 is a one-texel outline, on every sprite and at every zoom.
Neither of the other two modes can express that. Metres go through Pixels Per Unit, so the same profile draws three texels on one sprite and ten on another and the number in the field means nothing on its own. Screen pixels go through the camera, so an outline authored as two texels is two and a bit at one zoom and one and a half at the next — which is the definition of not being pixel art.
Switching the mode does not convert the number. The factor is a sprite's Pixels Per Unit and a profile is worn by however many sprites happen to wear it, so converting against one of them would rewrite the width for all the others. The Inspector says what it did and leaves a sensible value. Pressing Edit on a profile from a sprite that is drawing a pixel rim is the one exception: a particular sprite is in hand there, so the width is converted and the profile opens showing the outline already on screen.
What the artwork needs
- Filter Mode: Point on the sprite's own texture. The field can be perfect and the result still soft, because the blur happens afterwards to both at once.
- Whole-number scale on the object. A fraction spreads a texel across a fraction of a screen pixel and the outline's steps land between pixels.
- Equal X and Y scale, so that a texel is square in the world. Any value will do —
(3, 3, 1)is as good as(1, 1, 1); it is the ratio that stretches a texel and not the size. A smooth field absorbs a stretch, a pixel one cannot — see Stretched sprites. - No rotation, for the same reason: the outline is built on the sprite's own grid, and once rotated its steps no longer line up with the screen's.
The component checks all of these and says which one is in the way. It also offers the switch when it sees point-filtered artwork under a smooth field — an offer rather than a change, because an import setting is not a statement of intent.
Rim Falloff
Under Width Mode: Pixels the profile's softness slider is drawn as Rim Falloff (texels) and does something a grid can honestly do: it fades the outline a whole ring at a time, dimmest on the outside, brightening inward. The value is the share of the outline's own width that fades — 0 is a solid rim, 0.5 fades the outer half of its texels, 1 fades across all of them — so one setting reads the same on a two-texel rim and a twenty-texel one.
A one-texel rim has nothing to be dimmer than, so it simply takes the value as transparency.
Pulse
Pulse works on a pixel rim, and it steps. A smooth rim's edge is placed by a threshold on a continuous distance, so the geometry breathing is the effect; a pixel rim's edge is placed by a texel count, and a count can only move a whole texel at a time.
Two controls shape what that reads as, and both appear on the profile when Pulse is on:
- Step Fade eases the outline down to its faintest as it reaches full stretch and back to solid as it returns — once per pulse, in time with the width. The steps do not disappear; they stop being the loudest part of the effect, because the ring is at its faintest exactly where they come thickest. That is what makes a discrete rim read as a breath rather than as a stutter. It is deliberately tied to the wave and not to the texel boundaries: keyed to those it fires once per step, which on a wide rim is several times a beat and reads as flicker.
- Brighten scales how much the colour lifts at the top of the swing. The pulse widens and brightens together, and on a pixel rim — where the width can only step — the brightening is often the part that actually reads, and can easily be more than is wanted. 0 holds the colour still and lets the width do all the work.
12.4 X-Ray needs an occluder
In 3D the depth buffer answers "is this pixel behind something", and X-Ray is built on that question. A 2D scene leaves that buffer empty, so Keyline supplies the missing input: an occluder writes a depth value derived from its place in the sorting order, and the existing machinery works unchanged.
Nothing else writes it. Without an occluder in the scene, X-Ray has nothing to draw against — the pass is configured, costs nothing and shows nothing. There are two ways to declare one:
- Keyline Occluder 2D on the sprite itself, when you know which objects hide things.
- Keyline Occluder Layers 2D, one component in the scene, naming whole sorting layers. Every sprite on those layers becomes an occluder without a component of its own.
Both of these say who occludes, and that stays a decision for the whole scene. It has to be: the depth an occluder writes is shared, so a proxy built for one sprite hides everything behind it, not only the sprite that asked for it. A per-object "which occluders apply to me" would be describing something it cannot control.
Which of those occluders a given outline can be seen through is a separate question, and that one is per outline. It is answered by X-Ray Sees Through on the sprite's own component, using GameObject layers rather than sorting layers — see 3.5. So a sprite can be an occluder for the scene and still let one particular character's X-Ray through, which sorting layers cannot express: a sorting layer is one number shared by everything drawing against it.
The registry sweeps the scene on an interval. A project that knows when its occluders appear can switch Auto Rescan off and say so directly:
layers.Add(spriteRenderer); // one sprite, no scene sweep
layers.Remove(spriteRenderer); // let one go
layers.Rescan(); // sweep now
An occluder must rank above the sprite it hides. The depth value comes from sorting position — sorting layer first, then order within it — and the comparison is strict: an occluder sharing a sprite's layer and order does not hide it. Duplicating an occluder is the usual way to meet this, because a duplicate inherits both numbers. Give it a higher order, or a sorting layer further along. Both Occluder inspectors have a Log Occluder Diagnostics button that prints every live occluder, its rank, and whether each X-Ray sprite shows through it.
12.5 Limitations worth knowing
- Draw Mode must be Simple. Sliced and Tiled draw a nine-patch or a repeat whose mapping from surface to texture is piecewise; the outline is one quad measured through a single mapping. The component says so in its status line when it finds one.
- Sorting orders beyond ±512 within a layer are clamped to that layer's edge, so two sprites out there share a depth value and X-Ray between them cannot be resolved. The clamp is deliberate: a sprite compared at the wrong depth, in the neighbouring layer's range, is a worse outcome than one compared at the edge of its own. Negative orders are fine — every distinct position in the scene claims a depth slot of its own, whatever its sign.
- Edge Softness is a mesh control. A rim drawn from a distance field softens through Rim Softness instead, which needs no extra pass. Under a pixel field neither applies: the same slider becomes Rim Falloff and fades a whole ring at a time, which is the only kind of fade a grid edge can honestly have.
13 · Scripting
13.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.EdgeSoftness = 2f; // pixels; 0 is hard. Needs the Soft Edge pass.
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. Add KeylineOutline, though — the attributes that make it behave
like a component, ExecuteAlways among them, are on the concrete class.13.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.
13.3 Driving the passes
Switches and profiles, addressed by KeylinePassKind:
outline.SetPassEnabled(KeylinePassKind.XRay, true);
bool on = outline.IsPassEnabled(KeylinePassKind.MainInterior);
KeylineOutlineSettings fill = outline.PassSettings(KeylinePassKind.MainInterior);
outline.OutlineEnabled = false; // the master switch, above all of them
KeylinePassKind names six passes: Primary, XRay,
MainInterior, XRayInterior, and — on a mesh only —
MainCutoutOverlay and XRayCutoutOverlay. A sprite has four mirrors and no
overlay pass, so it answers false for those two whatever you set, and
SetPassEnabled on them does nothing. That is not the same as an empty slot, whose
switch keeps whichever position you gave it — it is a pass that does not exist. The alternative, a
default branch quietly treating them as the visible rim, is the silent wrong answer
that makes generic code look like it worked.
Switch position or what is on screen
IsPassEnabled is the position of a switch. Set it and read it back and the two
agree, which is what a save system needs. A pass whose switch is on but whose slot is empty draws
nothing and still answers true.
bool switchedOn = outline.IsPassEnabled(KeylinePassKind.XRay); // the switch
bool visible = outline.IsPassDrawing(KeylinePassKind.XRay); // switch, slot and master
Editing a pass that is not the active one
Every member that changes a pass names it in the call, with one exception:
SetStyle takes a style and nothing else. An edit scope is how that one is told which
pass it means — and, because it holds the rebuild back until it closes, how a burst of changes
costs one rebuild instead of one each:
using (outline.EditScope(KeylinePassKind.XRay))
{
outline.SetStyle(KeylineStyle.Neon);
outline.Color = new Color(1f, 0.55f, 0.1f);
outline.Width = 0.03f;
} // one rebuild here, not three
Scopes nest, so a helper may open its own inside a caller's; only the outermost close does the
work. BeginSettingsEdit and EndSettingsEdit are the same pair by hand —
prefer the using form, because a throw between the two leaves the outline with a scope
open for ever and every later change silently deferred.
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.
13.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 every pass — the four ordinary ones and both Cutout Overlays — and refreshes. A texture swap is a fact about the source material rather than about one pass, and the X-Ray rim reads the same texture as the Main one.
13.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<KeylineOutline>();
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.
OutlineEnabled is the same switch as a property.Several passes at once
Filling four slots one at a time is four structural changes, and three of the four rebuilds they
cause are thrown away by the next assignment. That is invisible when a person fills a slot a minute
and very visible in code that spawns a hundred objects. Configure does the lot in one
build:
var outline = enemy.AddComponent<KeylineOutline>();
outline.Configure(new KeylineSetup
{
Main = hostileProfile,
XRay = seeThroughProfile,
XRayStyle = KeylineStyle.Neon, // optional; null keeps the profile's own
Enabled = true, // optional; null leaves Enabled On Start to decide
});
Or, for the common case of profiles and nothing else:
outline.Configure(main: hostileProfile, xray: seeThroughProfile);
A KeylineSetup describes the whole outline, not a patch on one. Every slot is
assigned exactly what the setup names, and a slot it leaves null is emptied; a pass draws when its
slot has a profile. That is the only reading under which applying the same value twice gives the
same result, which is what a setup has to promise. To change one thing, write to that one thing.
A style named in the setup goes through SetStyle, so it follows Instance Override
exactly as a direct call would — see 13.2.
13.6 What is cheap and what rebuilds
Three tiers, and it is worth knowing which one a line falls into.
| Costs | What |
|---|---|
| A material write | colour, opacity, width, glow, gradient offset — everything the shader reads. Fine every frame. |
| A flag per renderer | SetPassEnabled, OutlineEnabled,
SetEnabled. Showing and hiding an outline does not rebuild it. |
| A rebuild | putting a different profile in a slot; a style whose shape differs from the last one (Halo, Double, Frost); the expand mode; the feature mask. The generated objects are made again. |
Two switches in the second row do rebuild, and only in one combination: turning the visible rim or the X-Ray rim on or off while the other is running. The two share a stencil arrangement, and the extra stamps that carries do not exist until they are built. Every other switch is a flag.
Pooling
Return an object to a pool with OutlineEnabled = false and take it out again with
true. Neither destroys anything, so the second is nearly free. Do not remove and re-add
the component, and do not clear the slots: both throw away the work the pool exists to reuse.
If a pool holds many outlines dark for long stretches and the memory matters more than the moment one comes back, switch Keep Mirrors While Hidden off under Project Settings ▸ Keyline ▸ Runtime. Hidden outlines are then dismantled and rebuilt on the way back in, which is what every version before 1.2.0 did.
When a write does nothing, and when it does too much
Two mistakes are silent, and both are reported — switch the messages off under Project Settings ▸ Keyline ▸ Runtime ▸ Report API Misuse if the console noise is not wanted. Each is said once per outline and pass.
- Writing a look property while the pass's slot is empty goes to a scratch profile and changes nothing. Put a profile in the slot first. Reported in the Editor and in development builds.
- Writing one with Instance Override off, while the slot holds a project asset, changes every object using that profile — and in the Editor the asset is modified on disk, so the change outlives play mode. Reported in the Editor only: telling a project asset apart from a ScriptableObject you created and assigned on purpose means asking the asset database, and a player build has none.
13.7 One script for a mesh or a sprite
IKeylineOutline is what the two components have in common: the passes, the slots,
the three look properties they agree on, the edit scope and Configure. Anything that
drives outlines it did not create — a spawner, a save system, a highlight of your own — can hold
this instead of a concrete type and work on both.
IKeylineOutline outline = go.GetComponent<KeylineOutline>() as IKeylineOutline
?? go.GetComponent<KeylineOutline2D>();
outline.Configure(new KeylineSetup { Main = profile });
outline.SetPassEnabled(KeylinePassKind.XRay, true);
== null does not work on an interface reference. Destroying a
component does not clear the C# references to it; Unity fakes the appearance by overloading
== on UnityEngine.Object, and that overload is chosen from the static type
of the operand — so through an interface reference it is never chosen at all, a destroyed component
reads as present, and the next member access throws. Use the extension provided for it:
if (outline.IsAlive()). A loop over other objects' outlines that throws halfway simply
stops, and everything after the destroyed entry is silently skipped.What the interface deliberately leaves out is the look properties past those four. A sprite and a
mesh agree on colour, width and opacity; past those the two diverge — hull extrusion has no meaning
on a quad, a distance field has none on a mesh — and an interface that promised them would be
promising a no-op on one side. Reach the rest through ForkSlot, which hands back the
profile itself.
Handing a borrowed pass back
An effect that takes a pass over, writes a temporary look into it and later gives it back has an
ordering to respect, and getting it wrong fails quietly. KeylineSlotRestore.ToShared
does it correctly:
using FourSsets.Keyline.Recipes;
KeylineSlotRestore.ToShared(outline, KeylinePassKind.MainInterior, myForkedProfile);
It lives beside the recipes, in FourSsets.Keyline.Recipes, because that is where the
trap was found — but it is public for anything that borrows a pass, not only for them. Follow it
with your own RefreshAppearance(): it restores the style and copies the values back,
and putting them on screen is the caller's call.
The style has to be set before the values are copied back — SetStyle returns early
when the profile already holds the style being asked for, so it has to run while the slot still
holds the effect's style and the difference is visible. A plain value copy raises no event and
rebuilds nothing, so a Frost profile restored the other way round comes out still called Frost with
its shell mirrors destroyed, drawing a flat band until something else happens to rebuild it.
13.8 What is stable
Public members of KeylineOutline, KeylineOutline2D,
KeylineOutlineSettings, IKeylineOutline, KeylineSetup,
KeylinePassKind, KeylineStyle and the recipe components are the supported
API. Anything added to KeylinePassKind or KeylineStyle goes on the end, so
serialized values keep their meaning.
Types whose names say what they are for internally — the pipeline passes, the merge resolver, the stencil pool, the runtime manifest — are public because assembly boundaries require it, not because they are an API. They may change in any release.
Anything that does change in a way a project can notice is listed under Changed,
Deprecated or Removed in CHANGELOG.txt, with what to do about it.
14 · 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.
Soft Edge is extra only when it is actually drawing: the pass is in the pipeline and at least one rim has Edge Softness above 0. That frame pays for an off-screen colour target, a depth copy, a few Kawase blits and a composite. Softness 0, or no pass, costs nothing extra — those rims stay on the camera's transparent path.
14.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. Four things ask for one, and three of the four only on an
object built from several renderers — a single-renderer prop writes the same values onto the
shared material and stays batched.
- Cutout. The source texture is resolved per renderer and bound through a block. This one applies however many renderers there are.
- A style that is told how big the model is — Gradient always, and Frost, Electric, Lava, Smoke, Crystal, Water and Fire in Object or World space. Their density is a proportion of the model rather than a measurement in metres, and on a figure of six meshes the six sizes differ.
- A style that places something — a pattern, a noise field, a ramp — in Object or World space. Every part's coordinates have to be read through the figure's own space, so the prop wears one copy of the effect instead of six.
- Planar or Mixed expand with the expand centre at Mesh Bounds, which is the default, or with Solidify thickening on. Both are per-mesh quantities.
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. Solid, Neon, Halo and Double place nothing and read no size, so a crowd drawn in those stays batched whatever it is built from.
14.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, Lava, Smoke, Crystal, Water, Fire | 1 |
| Frost | 2 — core plus shell |
| Double | 3 — two rings and the gap mask |
| Halo | one per layer, 2 to 8 — one on a sprite |
14.3 The stencil budget
126 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. An Overlay on an object spends a second pair of its own — that cost does not fold into the group.
14.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 14.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 14.2 predicts the shape of the cost, not the milliseconds.
The Mobile Solid profile
| Profile | Frame, 200 objects |
|---|---|
| Full | 2.02 ms |
| Mobile Solid | 2.04 ms |
No measurable difference on a desktop GPU, and that is the expected result. Mobile Solid 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.
14.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.
14.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 all fifteen styles are present.
Mobile Solid 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 a Mobile Solid Style Profile, moving to an ordinary one only when you reach for something it does not carry — see 15.5 for what that is. On desktop there is no reason not to run Full.
Both kinds are made from Assets ▸ Create ▸ Keyline, and the New button on a
pass card copies whichever kind the slot already holds — so a Mobile Solid project stays that way without
anybody having to remember. If a bundled profile is deleted,
Tools ▸ Keyline ▸ Restore Bundled Style Profiles puts it back, Mobile Solid included.
15 · 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.
15.1 Nothing is drawn at all
- The pass has no profile. A slot with no profile 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.
15.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 13.2.
15.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 — 13.4.
15.4 Cutout on a solid model
Experimental — same status as Cutout Overlay. The alpha rim sits on the surface (extrusion is off while Cutout is on). The outer silhouette is a normal hull outline, or a Cutout Overlay (also experimental) on the same pass. Padding that widens a flat card in-plane is skipped on volumes — Cutout still traces alpha.
A flat mesh is still the cleanest card for padding: fences, foliage, decals, a sprite texture on a quad. Single- or double-sided makes no difference; the in-plane grow needs a plane.
15.5 Mixed or a mesh Cutout does nothing on a Mobile Solid profile
Mobile Solid has no vertex path for Mixed — its hull is Normal or Planar and nothing else — and it does not clip a mesh against a texture's alpha. That absence is most of where the saving comes from, and the trade is flat and thin meshes, which Mobile Solid gets wrong.
A sprite is the exception, and it is not a limitation. Every sprite rim is a distance field rather than a hull, so Mobile Solid draws it from the same shared code Full uses and its Rim settings all apply. Solidify — Sheet Thickness, Sheet Lateral, the Thicken axes — works on Mobile Solid too, under Planar.
For the rest, point the pass at an ordinary Style Profile, or accept the limitation deliberately rather than tuning against it.
15.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.
15.7 Edge Softness does nothing
The rim stays hard until the Soft Edge pass is in the pipeline. The profile says so beside the
Edge Softness slider and offers a button, which is the place it belongs — where the value
that needs the pass is being edited. The same switch project-wide is
Project Settings ▸ Keyline ▸ Rendering Passes ▸ Soft Edge.
Interior Fill never reads the value, and neither does a rim drawn from a distance field: with Cutout on, or on a sprite, softening is Rim Softness instead and the Edge Softness slider is hidden. If it disappeared from a profile you had already tuned, that is why — switching a profile to Cutout changes which of the two controls its edge. Built-in RP is not supported.
Each profile uses its own world radius. One object's Edge Softness does not change another. The halo stays around the object as the camera moves — it does not spread in screen pixels.
15.8 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.
15.9 Outlines interfere on a crowd
The stencil pool hands out 126 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.
15.10 The SRP Batcher broke
Any MaterialPropertyBlock takes a renderer out of the batcher. Cutout always asks
for one; a style told how big the model is, a style that places something in Object or World space,
and Planar or Mixed expand from the mesh bounds each ask for one on an object built from several
renderers. See 14.1 for the full list and for what stays batched.
All of them are worth the cost where they are needed and worth avoiding on a crowd.
15.11 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 ▸ Keyline ▸ Fix Demo Materials For This Pipeline. The outline shaders
are never involved — they carry a SubShader per pipeline.
15.12 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.13 The pixel art outline is soft, or its corners are round
- The field is still Smooth. Field Mode says what to draw; the file says what can be drawn. Rebuild after changing it — the rounding is in the numbers, not in how they are read.
- The sprite's own texture is filtered Bilinear. The outline is on the grid and gets blurred along with the artwork. Set its Filter Mode to Point.
- A fractional scale or a rotation. Either spreads a texel across a fraction of a screen pixel, and the steps land between pixels.
- Unequal X and Y scale. One texel is then not square in the world, so the outline comes out thicker on one axis. A smooth field absorbs this; a pixel one cannot — Stretched sprites.
The component reports each of these; see 12.3.
15.14 Width does almost nothing on a pixel art sprite
The profile is in World or Screen mode, where Width is a length. On a sprite at 100 Pixels Per Unit a width of 0.1 is ten texels and the slider crosses several of them at once; on another sprite the same number is three. Set Width Mode to Pixels and the field becomes a whole texel count with a step button either side — 12.3.
15.15 The sprite's outline is thicker on one side than the other
The object's X and Y scale are not equal, so one texel is not square in the world. Make them
equal — any value will do, (2, 2, 1) is as good as (1, 1, 1), since it is
the ratio between them that stretches a texel and not the size.
On a smooth field there is nothing to do beyond rebuilding: the field is measured on the sprite's own stretched grid and the rim comes out even. Change the scale afterwards and the field is measured for a stretch it is no longer drawn at, which the Inspector says. On a pixel field the scale is the only fix — Stretched sprites.
15.16 A pixel art Pulse flickers instead of breathing
The width of a pixel rim can only change a whole texel at a time, so a rim of six texels swinging by half crosses three boundaries going out and three coming back. Raise Step Fade on the profile: it eases the outline to its faintest at full stretch and back, once per beat, so the steps land where the ring is least visible. If the pulse reads as a flash rather than a swell, lower Brighten — on a stepping rim the colour swing is often doing more of the work than the width is — 12.3.
15.17 What is different on HDRP
Keyline draws the same outlines on both pipelines, but HDRP reaches some of them by a different route, and four consequences of that are worth knowing before they look like faults. Nothing here is a setting to change — they are what HDRP is, and Keyline works within them.
Soft Edge changes how X-Ray is drawn, project-wide. HDRP leaves an outline shader two usable stencil bits, which is not enough to fence an X-Ray family against a soft rim on the camera. So when the Soft Edge pass is installed, Keyline draws the whole X-Ray family — rims and Interior Fill — inside that pass instead of on the camera, for every object, at any Edge Softness. Switching Soft Edge on or off therefore changes the route X-Ray takes. It does not change what it looks like, and if nothing in the scene has softness above zero the pass stands aside and the ordinary route is used. URP fences with the camera stencil and only moves soft rims.
Outlines composite before post-processing. The Soft Edge pass runs at
BeforePostProcess, so on HDRP an outline that goes through it is post-processed with the
rest of the frame: temporal anti-aliasing, depth of field, motion blur and any upscaler act on it. A
thin rim under TAA can shimmer, and depth of field will blur an X-Ray rim belonging to an object
that is itself in focus. On URP the rims are ordinary transparent geometry and take the same route as
everything else in the scene.
Outline colour is not exposure-compensated. Keyline writes the colour you chose, and HDRP's colour buffer is scaled by the camera's exposure. Under Fixed exposure that is stable and the colour is the colour. Under Automatic exposure the scene re-meters as the camera moves and the outline does not follow, so a rim that reads correctly in a bright room will read differently in a dark one. Pin the exposure, or pick the colour in the lighting the outline will mostly be seen in.
Outlines do not appear in ray-traced effects. The shaders carry no ray tracing passes, so
an outline is drawn in the direct view only — never inside a ray-traced reflection, ray-traced global
illumination, or the path tracer. Project Settings ▸ Keyline says so when ray tracing is
enabled in the HDRP asset.
And one that is fatal rather than awkward: both of Keyline's HDRP passes are Custom Passes. If Custom Pass support is switched off in the HDRP Asset, or the Custom Pass frame setting is off on a camera, they cannot run — Soft Edge and X-Ray Layer Filtering are then missing with the switch still reading On. The settings page reports the first; the second is per camera and worth checking there if one view has outlines and another does not. Ordinary outlines are unaffected either way.
16 · Reference
16.1 Menu paths
| Menu | Entry |
|---|---|
| Add Component | Keyline ▸ Keyline Outline 3D |
| Add Component | Keyline ▸ Keyline Outline 2D |
| Add Component | Keyline ▸ Draw Config — 3D only, one per scene |
| Add Component | Keyline ▸ Merge Group — meshes and sprites alike |
| Add Component | Keyline ▸ Keyline Occluder 2D |
| Add Component | Keyline ▸ Keyline Occluder Layers 2D |
| Add Component | Keyline ▸ Recipes ▸ … — thirteen entries |
| Project Settings | Keyline — project-wide switches, 16.4 |
| Assets ▸ Create | Keyline ▸ Style Profile |
| Assets ▸ Create | Keyline ▸ Mobile Solid Style Profile — the same asset drawn by the smaller shader |
| Tools | Keyline ▸ Bake All Outlines In Scene |
| Tools | Keyline ▸ Settings — opens the page above |
| Tools | Keyline ▸ Refresh Pipeline Defines |
| Tools | Keyline ▸ Restore Bundled Style Profiles |
| Tools | Keyline ▸ Fix Demo Materials For This Pipeline — only while the demo folder is present |
| Tools | Keyline ▸ Add Draw Config |
16.2 Enumerations
All in namespace FourSsets.Keyline.
KeylineStyle
Solid · Neon · Halo · Sketch ·
Pattern · Double · Electric · Rainbow ·
Gradient · Frost · Lava · Smoke ·
Crystal · Water · Fire — 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 |
MainCutoutOverlay | the Cutout Overlay drawn with the Main rim — mesh only |
XRayCutoutOverlay | the Cutout Overlay drawn with the X-Ray rim — mesh only |
A sprite answers false for the last two and ignores an attempt to switch them: it
has four mirrors and no overlay pass.
KeylineWidthMode
World | metres; the outline keeps its proportion to the object |
ConstantScreen | pixels; the same thickness at any distance |
Pixels | whole texels of the sprite's texture; 2D pixel art |
KeylineSdfFieldMode
Smooth | Euclidean distance, sub-texel edge; round corners |
PixelArt8 | Chebyshev — a diagonal step costs one; outlines diagonals |
PixelArt4 | Manhattan — a diagonal costs two; thinner, no diagonals |
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 |
KeylineBlendMode
Default | the style decides, as it always has |
Alpha | ordinary transparency — the only mode where Opacity covers |
Additive | light; the background always shows through |
Screen | light that approaches white instead of clipping at it |
Multiply | ink; darkens and tints rather than covering |
Subtract | the colour taken out of the frame |
Darken · Lighten | whichever is darker or brighter, channel by channel |
See 5.5.
KeylineShaderProfile
Full | every style, cutout, Mixed expand |
MobileSolid | Solid only, Normal or Planar expand, no mesh cutout — fewer variants and less fragment work. Called Light up to 1.1; the value is stored as a number, so renaming it changed no asset. |
KeylineShaderFeatures
A [Flags] mask recording which shader variants a build needs:
None, Cutout, StyleNeon, StyleHalo,
StyleSketch, StylePattern, StyleDouble,
StyleElectric, StyleRainbow, StyleGradient,
StyleFrost, StyleLava, StyleSmoke,
StyleCrystal, StyleWater, StyleFire,
PlanarExpand, AllStyles, All. AllStyles is
every style bit; All is that plus Cutout and
PlanarExpand.
16.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/OutlineMobileSolid (Mobile Solid). Which one draws is decided by the
profile's
type: an ordinary Style Profile is drawn by Full, a Mobile Solid Style Profile
by the other. Both components read that and swap the material with it, which is why pointing a pass
at the other kind of profile rebuilds rather than reconfigures.
Profile_MobileSolid asset, the C# type
KeylineMobileSolidOutlineSettings and the
KeylineShaderProfile.MobileSolid value. It was called Light up to 1.1,
which read as a lighting feature: the one thing it has nothing to do with.It used to be a dropdown on one profile, and that was the wrong shape for it. Mobile Solid is a different program with a different set of values it can honour — one style, no cutout branch on a mesh — so a profile switched to it went on saying Halo in every field the page still showed and drew a plain line. A type cannot do that.
It works on sprites too. It draws Solid and nothing else, with no style branches to step through, and it measures its rim against the same distance field the Full shader uses — from the same shared code, so the two cannot come out different shapes. On a phone that is the largest single saving available to a 2D project.
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.4 Project Settings
The project-wide switches live under Project Settings ▸ Keyline, or
Tools ▸ Keyline ▸ Settings, which opens the same page. They come in two kinds, and the
difference matters more than it looks.
Rendering Passes — Soft Edge and X-Ray Layer Filtering — are the only two switches on the page that install something. Ticking one puts Keyline's pass into your render pipeline; unticking it takes the pass out again. Both are project-wide in both pipelines: nothing is added to any scene, and nothing has to be repeated per scene.
Keyline asks about them once, on the first reload after import, and never again. Say yes and both go in; say no and nothing is written and nothing is asked a second time — the switches are here whenever you want them. Neither is on until somebody says so, because on URP turning one on writes a renderer feature into your own Universal renderer assets, and a package has no business editing those before it has asked. Without them Edge Softness has no effect and every X-Ray Sees Through mask is ignored; both say so where they are set, and the rest of Keyline works exactly as it does with them.
Where the switch is kept differs by pipeline, and it is worth knowing which you have.
- URP — the switch is the renderer. The pass is a renderer feature on every Universal renderer the project uses, which is an asset already in your version control and already read by every build. There is nothing else to store.
- HDRP — a custom pass has no renderer asset to live on, so the choice is stored with Keyline's other settings and a hidden pass volume is spawned from it: in edit mode, in play, and in a build. The volume is not saved into any scene and does not appear in your hierarchy. A build gets the setting through a small Resources asset that Keyline writes just before the build and deletes straight afterwards, so nothing of it is left in your project.
Because both are one project-wide answer, the Soft Edge button on
Keyline Outline 3D and this checkbox are two views of one thing rather than two
settings, and cannot disagree.
Each switch says whether it is actually working, in a line under it, in Edit Mode and in Play Mode. On is not the same as working, and the two ways they part company are pipeline-specific, so you are only told about the one that can happen to you:
- URP — installing writes to every renderer in the project, including the one on each quality level. A quality level added afterwards brings its own renderer, and the pass is not on it: the game then looks right on one quality setting and wrong on another. The status line counts them — "On 2 of 3 renderers" — and offers a button that adds the pass to the rest. This is the one thing that can quietly un-cover a URP project.
- HDRP — quality levels cannot affect a custom pass and neither can scenes, so there is
nothing of that kind to watch for. What an upgraded project can still have is a
Keyline Soft Edge PassorKeyline X-Ray Occluder Passobject saved into a scene by Keyline 1.1. It still works and is no longer needed; the status line points it out and offers to remove it.
The switches are read-only while the game is running — installing edits assets, and doing that mid-play gives you a change the running game has already gone past. The status line stays live.
Turning a pass off changes nothing you have authored. Soft Edge off draws rims hard, X-Ray Layer Filtering off ignores every X-Ray Sees Through mask and gives you the ordinary X-Ray — no component is edited and no value is lost, so ticking it back on restores everything exactly.
Reset To Defaults covers them too, and asks first when it would uninstall a pass. Every other switch on the page is a preference; these two change what your project renders, and a reset that did it unannounced would be doing more than the button says.
Four more are Editor preferences. Two are on out of the box and two are off, and the split is not about how useful each one is. The two that are on are the ones whose off state is the confusing one — an outline that ignores a child just added to it, an outline that vanishes with the mesh asset behind it. The two that are off change how the Scene view answers a click and what a build contains: both are worth having, neither is worth starting unasked.
And two are neither. The Runtime section holds the only settings on this page that reach a player build — see below.
| Rendering pass | Default | What it does |
|---|---|---|
| X-Ray Layer Filtering | Off | Lets each outline choose which layers its X-Ray shows through, using the X-Ray Sees Through mask on the component — 3.5. Costs nothing until a mask is actually narrowed: with every outline seeing through everything there is nothing to test, so no buffer is allocated and no geometry is drawn. |
| Soft Edge | Off | Draws Main and X-Ray rims into a separate buffer and blurs them, so Edge Softness and
world-space halos work — 4.4. The same switch as the
buttons on Keyline Outline 3D. |
| Setting | Default | What it does |
|---|---|---|
| Mirrors Not Clickable | Off | Takes Keyline's generated mirror objects out of Scene view picking. A mirror sits on top of the object it outlines and is usually a little larger, so a click lands on the mirror rather than on what you meant to select — and dragging it moves the outline off the object until the next rebuild throws the edit away. Takes effect on the next rebuild. |
| Rebuild On Hierarchy Change | On | Rebuilds an outline in the Editor when the renderers under it change, so a child added to an outlined object gets an outline without pressing anything. The hierarchy event only prompts a comparison — the rebuild happens when the set actually differs, not every time something moves in the scene, so an unrelated change costs one walk of a cached list. On by default: off, the child appears with no outline and nothing says why. Turn it off if you would rather rebuild only when you press the button. Editor only, and it covers every outline in the project; for play mode and builds there is a per-component switch instead, below. The two never act at the same moment. |
| Rebuild On Baked Mesh Deleted | On | Rebuilds outlines whose saved bake has just been deleted from the project. A mirror is handed the baked mesh directly, so deleting the asset leaves it holding nothing and the outline disappears — with no message, and it stays gone until something unrelated forces a rebuild. On by default: this one never runs unless something has already broken, and the alternative is a silent failure whose cure is unguessable — the outline comes back when you toggle the profile, which teaches the wrong lesson about what fixed it. |
| Strip Unused Shader Variants | Off | Leaves out the shader variants for styles no profile in the project uses: smaller builds, shorter shader compiles. The risk is that a style chosen at run time — assigned from code, or on a profile authored later — has no variant to draw with and falls back to Solid. A plain outline where a Neon one was asked for is harder to notice than nothing at all, so turn it on deliberately and check the build. |
Runtime
Two switches that change what the game does rather than what the Editor does, and the only ones on this page that reach a player build. They travel in a small manifest Keyline writes at build time and deletes afterwards, so a project that never opens this page runs on the defaults below and gets no extra file in its Assets folder.
| Setting | Default | What it does |
|---|---|---|
| Keep Mirrors While Hidden | On | An outline switched off leaves its generated objects in place with their renderers disabled, so switching it back on is a flag per renderer and costs nothing. Off, they are destroyed and the next show builds them again — less memory held by objects nobody is looking at, paid for at the moment somebody looks. On by default because the hide-and-show cycle is the common one: a hover, a selection, a ping. Turn it off when a scene hides many outlines at once and leaves them hidden. See 13.6. |
| Report API Misuse | On | Reports the two writes that fail silently: a look property written while the pass's slot is empty, and one written onto a shared project asset with Instance Override off. Each is said once per outline and pass. The first is reported in the Editor and in development builds; the second in the Editor only, because telling a project asset apart from one you made yourself means asking the asset database. A release build never checks — neither test is compiled into one. See 13.6. |
ProjectSettings/KeylineSettings.asset,
outside Assets. Reimporting Keyline cannot overwrite them, and version control carries
them, so a team and its build machine agree — which matters most for Strip Unused Shader
Variants, since it decides what ships.Auto Rebuild In Play Mode, per component
The switch above covers the Editor and nothing else — it is compiled out of a build entirely. Both outline components carry their own Auto Rebuild In Play Mode under Setup for the other half, off by default — and the reason the defaults differ is that the two watch very different things. In the Editor a hierarchy change is a rare, human-speed event, so watching for one costs nothing. In a build it is a standing cost paid to catch a case most projects never have: code that spawns or destroys part of an outlined object without telling anything.
It listens for Unity's own child-list message rather than polling, so an untouched object pays
nothing per frame. What that message reports is the object's direct children: a renderer
added deeper down is not reported by Unity and needs Rebuild(), which is one call and
is already to hand in the code doing the restructuring.
17 · 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.