RRév O'Conner
The Himalayas, IN · UTC+5:30
← All writing
CGI2026 · April · 1810 min read

Previewing Runtime Decals in the Unity Editor

Bridging a plugin's runtime decal system into edit mode so artists can debug projections without entering Play.

Most Unity decal plugins ship with two parallel systems and one of them is always inconvenient. Decalery is no exception. There is an editor-time path built around the DecalGroup component, which projects mesh-based decals onto receivers you assign by hand and gives you Live Preview right in the scene view. Then there is a runtime path built around DecalSpawner and DecalManager, which fires raycasts, picks up surface materials, and pools the resulting decal geometry. The two systems share a name but almost nothing else.

The project I was working on uses the runtime path exclusively. A DecaleryProjector component sits on a child GameObject inside a particle prefab. When the parent VFX spawns at the impact point of a bullet or grenade, the projector raycasts along its forward axis, finds whatever surface it lands on, and asks DecalSpawner to drop a decal there. It is the right design for a system that needs to react to runtime collisions, but it has one annoying property: nothing happens in edit mode.

This blog is about the editor tool I built to work around that, what it actually replicates from the runtime path, and the small reflection tricks that made it possible without modifying the plugin.

The friction

The workflow that triggered this was completely ordinary. I had a particle prefab containing a DecaleryProjector on a nested child, oriented to point straight down. I dragged it into a test scene, placed it just above a flat ground plane, and wanted to see what the decal would look like on that surface. Position, rotation, scale, material, the whole composition.

There is no built-in way to do that. Enter Play mode and the decal appears, but Play mode kills your selection state, breaks Inspector tweaking, and forces you to reset the camera every iteration. Live Preview belongs to DecalGroup, which is the other system, so it does not help. The DecalSpawner API requires DecalManager to be initialised, which only happens at runtime when its Awake runs and registers the singleton.

What I needed was something that read the same data the runtime projector reads, did the same raycast the runtime projector would do, and dropped a visual approximation of the decal on the hit point. It did not need to be pixel-perfect. It needed to be close enough that I could judge placement and orientation without entering Play mode.

Reading what the runtime would have read

The first job was figuring out what DecaleryProjector actually does at runtime, since that is what the editor tool needs to mirror. The component is small. It holds a reference to a DecalType asset and a few private fields for maxDistance, layerMask, and renderingLayerMask. On spawn it does a Physics.Raycast along transform.forward, takes the hit point and normal, and passes those into DecalSpawner.Spawn along with the DecalType.

A DecalType is a ScriptableObject that wraps a nested decalSettings object, which is where the actual decal data lives: the material, the width and height, blend settings, and so on. None of this is exposed through public properties. The plugin uses Decalery's inspector to surface them in the editor, and at runtime its own code reflects on its own internals to read them.

For a tool I am writing in a folder I do not own, the path forward is reflection. Two BindingFlags.NonPublic | BindingFlags.Instance lookups: one to grab decalSettings off the DecalType, one to walk the fields on decalSettings and find the first one of type Material. That gives me the material the runtime decal would be rendered with. Width and height come off the same settings object the same way.

The same pattern applies to the projector's own private fields. I expose them by name through a small helper:

private static T GetPrivateField<T>(object target, string fieldName)
{
    FieldInfo field = target.GetType().GetField(fieldName,
        BindingFlags.NonPublic | BindingFlags.Instance);
    if (field != null)
        return (T)field.GetValue(target);
    return default;
}

It is not elegant. It is the price of using a plugin without forking it.

Replicating the raycast

Once the data is readable, the projection itself is a single Physics.Raycast call. Origin is the projector's world position, direction is its forward, distance comes from the private maxDistance field, and the layer mask comes from layerMask. If it hits, you get a hit point and a hit normal. From there, building the preview quad is mechanical: place a quad at the hit point, rotate it so its surface normal aligns with the hit normal, scale it to the decal width and height, and assign the material extracted from the DecalType.

The detail that ate the most time was orientation. Decalery's runtime projector projects along its forward axis, but the decal itself is a flat quad whose normal points back at the projector. Quaternion.LookRotation(hit.normal) builds a rotation whose forward is the normal, which means the quad's normal faces the camera direction, which is wrong. The fix is Quaternion.FromToRotation(Vector3.up, hit.normal) applied as a child of a parent that respects the projector's roll, but in practice for testing a flat decal on a flat ground plane, Quaternion.LookRotation(-hit.normal) is close enough.

There is a separate axis problem worth mentioning. The runtime projector uses its own forward, which is fine when the prefab is authored to point at the surface. But when an artist is testing a prefab that has not been oriented yet, or just wants to project straight down regardless of the prefab's transform, the tool needs to override that. I added a small enum for raycast direction that defaults to negative Y, with options for the other world axes and a ProjectorForward choice that defers to the component.

What the GUI ended up looking like

I started with a menu item. The first version was a single [MenuItem] that took the current selection, ran the raycast on whatever projectors it found, and dropped the preview quads. It worked, but every parameter tweak meant editing the script and recompiling, which is exactly the kind of friction the tool was supposed to eliminate.

The second version is an EditorWindow with four sections. Selection at the top shows which GameObject is active and how many DecaleryProjector components were found on it and its children. Raycast Settings exposes the direction enum, the max distance override, a layer filter toggle, and a layer mask picker. Preview has the generate button. Cleanup at the bottom shows the count of active previews and a clear-all button.

The whole point of the GUI was that the artist should be able to drop the prefab in, open the window, tweak one or two values, hit Generate, see the result, adjust, regenerate, and clean up. Every preview quad gets a name prefix that the cleanup pass searches for, and re-running on the same projector replaces the previous preview rather than stacking them. Undo is registered on every created and destroyed object, so Ctrl+Z works the way you would expect.

The pieces that did not generalise

A few things are worth flagging because they were specific to this plugin and would not transfer cleanly to another.

The material extraction assumes decalSettings has exactly one Material field. If Decalery ever adds a second one, the reflection walk picks whichever the runtime returns first, which may or may not be the right one. The preview also does not replicate Decalery's mesh-wrapping. Real decals at runtime wrap around the receiver geometry, so they fit curved surfaces. The preview is a flat quad, which is fine on flat surfaces and wrong on anything curved. For a test scene that is mostly ground planes and walls, this was good enough.

The private field names are also a fragile dependency. maxDistance, layerMask, and renderingLayerMask are the current names, and a plugin update could rename any of them without breaking its own runtime behaviour. The tool would silently fall back to defaults if that happened, which is not catastrophic but is the kind of failure that takes a while to spot.

Why it was worth doing

The Decalery preview tool is a small piece of code, around three hundred lines including the GUI. It does not solve a hard rendering problem and it does not push any boundaries. What it does is collapse a fifteen-second feedback loop into a two-second one. For an artist iterating on the position and scale of impact decals across dozens of weapon prefabs, that compounds quickly. The plugin's authors had no reason to build an editor preview for a runtime system, so the responsibility for that fell on the studio, and the cost of fixing it was much lower than the cost of not fixing it.

The broader lesson, if there is one, is that the line between a runtime system and an editor system is often thinner than it looks. The data is the same. The maths is the same. The only thing the runtime has that the editor does not is its initialisation chain, and you can often replicate just enough of that chain to get a preview without touching anything you are not supposed to touch.

Filed under: CGI← Back to writing