ZOA VFX
Surface-aware impact response: one Spawn call resolves what was hit and pulls the right decal, particle, and audio cue from the pool.
Every projectile that lands in a Foundry scene ends at the same call. A hitscan trace, a projectile impact, a shotgun pellet, or a melee strike each builds a HitImpactContext and hands it to IHitDecalService.Spawn. The service works out what surface was hit, which impact class hit it, which decal and particle that combination wants, pulls both from the pool, and publishes an audio cue for whoever is listening.
Surfaces here are semantic. Every concrete wall in a level reacts the same way, so the project authors one SurfaceClassDefinition for concrete instead of attaching prefab references to a thousand colliders. A collider only needs a way to vote for a surface, and the resolver offers four of them.
Everything spawned goes through com.zoa.foundation's IPrefabPoolService. Sustained automatic fire and shotgun scatter would otherwise allocate several objects per trigger pull down a naive Instantiate path. Pools are pre-warmed at scene install from each surface's authored budget, so the first burst does not hitch.
Depends on (2)
Depended on by (3)
How it works
Concepts
The surface resolution chain
SurfaceClassResolver walks four steps and the first hit wins. An explicit HitImpactContext.SurfaceOverride short-circuits everything, which is how a caller that already knows the answer skips the work: an AI damage receiver hitting a known body part supplies the Flesh definition directly.
Next comes the explicit marker. GetComponentInParent<SurfaceMarker> walks from the hit collider up through its parents and takes the nearest marker with a non-null Surface. Because it is the nearest that wins, a fine-grained marker on a head bone overrides a coarse one on the rig root, and dropping a marker on a prefab root gives the whole prefab a default with per-child exceptions.
Third is the GameObject tag. The resolver walks the same transform chain, asking the registry to match each ancestor's tag. This exists because tagged rigs usually carry the tag on the root only, and routing "Player" and "Enemy" straight to Flesh is far cheaper than authoring a SurfaceMarker on every body collider. Unity's "Untagged" default is explicitly rejected as a match so an accidentally untagged collider falls through rather than claiming whichever surface happens to list it.
Fourth is material name matching. The resolver finds a Renderer on the collider or the nearest parent, reads sharedMaterials so no instance material is created, and asks the registry which surface claims each material name. Matching is a case-insensitive substring check, so a Concrete surface listing "concrete", "rebar", and "rock" claims a material named Wall_Concrete_02.
If all four miss, the registry's Default catches it. The resolver only returns null when the registry itself is empty or no default was set, which in practice means a test fixture.
Impact classes make one surface behave several ways
A concrete wall should not react identically to a sniper slug and to one pellet of a shotgun spread. HitImpactContext carries an ImpactClassId, and SurfaceClassDefinition holds a sparse list of ImpactClassOverride rows keyed by that id. TryGetOverride does a case-insensitive lookup and returns null when the surface has no row, which is the signal to use its defaults.
The ids are strings rather than an enum. ImpactClass names the seven canonical values, bullet, buckshot, slug, beam, plasma, explosive, and melee, but a project that adds a bow-and-arrow archetype just types a new id. An enum would force every consumer to recompile against the package.
Overrides are sparse in both directions. A row can swap the decal prefab, the particle prefab, and the audio cue, and it scales the surface's authored size and lifetime through DecalSizeMultiplier and DecalLifetimeMultiplier, both defaulting to 1. Any field left null or at its default falls through to the surface, so a row that only wants a bigger crater sets one multiplier and nothing else.
The spawn path, in order
HitDecalService.Spawn resolves the surface, looks up the impact-class override, and folds the two into a single set of choices: decal prefab, particle prefab, audio cue, size, and lifetime. A null resolver, a null pool service, or an unresolved surface each ends the call quietly rather than throwing into the firing path.
The decal spawns with Quaternion.LookRotation of the negated normal, so a projector authored looking down its own negative Z lands flush against the wall. It spawns in world space with a null parent: parenting to a collider with non-uniform scale distorted decals in a way no per-axis counter-scale could fix once rotation mixed the axes. The cost, stated plainly in the source, is that decals no longer follow moving geometry.
After the pool hands back the instance, the service calls PooledHitDecal.Configure with the resolved lifetime and size. The component restarts its own timer and applies a uniform localScale, which is correct precisely because there is no parent chain to counter-correct.
The particle spawns at the same point with rotation aligned to the normal rather than against it, and gets a PooledParticleAutoDespawn added if the prefab does not already carry one. Finally, when a cue string survived the override fold, the service publishes a HitImpactAudioCueEvent on the Foundry event bus. VFX never creates an AudioSource.
Pooling and warmup
IPrefabPoolService keys pools by prefab reference, so two surfaces sharing a decal prefab share one pool rather than fragmenting into two. WarmupAll walks the registry and warms each surface's decal to its InitialPoolSize and its particle to a quarter of that, with a floor of four, because particles are shorter-lived and fewer are in flight at once.
PooledHitDecal implements IPooledLifecycle and owns its own retirement. It counts down from spawn, fades alpha over the last twenty percent of its window through a cached MaterialPropertyBlock rather than an instance material, and then despawns itself through the pool service. The fallback path destroys the GameObject outright if no pool service is registered, so a misconfigured scene leaks nothing permanent.
The timer lives on the decal, so the service's hot path carries no list of in-flight decals to scan every frame.
Audio leaves through the bus
When a resolved surface or its impact-class override declares a cue, HitDecalService publishes HitImpactAudioCueEvent carrying the cue id, the surface id, the impact class id, and the impact point and normal. Any audio package can subscribe; VFX depends on none of them.
That is the same decoupling rule the rest of Foundry uses. The package that knows what was hit publishes the fact; the package that knows how to make noise subscribes to it. Neither compiles against the other.
In the editor
Screens
Screenshot pending
/screenshots/vfx-surface-class-inspector.png
The inspector for a Concrete surface asset showing the surface id, decal and particle prefab slots, the material-name pattern array with entries like concrete and rebar, the decal size and lifetime sliders, and two or three ImpactClassOverride rows expanded so the size and lifetime multipliers are visible.
Screenshot pending
/screenshots/vfx-impact-class-matrix.png
A single concrete wall in play mode carrying four groups of decals: a medium bullet hole, a tight cluster of small buckshot marks, one large slug crater, and a wide blunt melee smear, so the per-impact-class size and tint differences read at a glance.
Setup
Workflow
- 01
Put a pool service in the scene first
VfxSceneInstaller resolves IPrefabPoolService during its Awake. Without a PrefabPoolSceneInstaller, or the canonical foundation scene installer, it logs a warning and registers nothing, and impacts spawn nothing at all. The execution orders are chosen so the pool wins the race, but the component still has to be present.
- 02
Author the surface catalogue
Create one SurfaceClassDefinition per surface through ZOA/VFX/Surface Class. Give each a distinct SurfaceId, its decal and particle prefabs, an audio cue id if it has one, and the material-name and tag patterns that should claim it. The bundled installer materialises a five-surface baseline of Concrete, Metal, Wood, Flesh, and Dirt if you want a starting point.
- 03
Wire the installer
Drop VfxSceneInstaller on a scene root and fill its bundledSurfaces list. Set defaultSurface explicitly, or leave it null and the first entry becomes the fallback. Leave warmupOnStart on unless you have a reason not to pay the one-time cost.
- 04
Mark the colliders that patterns cannot reach
Most geometry classifies itself through material names or tags. Add a SurfaceMarker where that fails: a sci-fi panel that looks like metal but should chip like concrete, a body part that must always read as flesh, or a prefab whose materials share no name with anything in the catalogue.
- 05
Call Spawn from every delivery site
Resolve IHitDecalService once in Awake and cache it. Build a context from the RaycastHit and pass the firing profile's impact class id. A null service is survivable, so guard the call and let the scene run without impact feedback rather than breaking the firing path.
- 06
Add impact-class rows only where the response should differ
Leave a surface's override table empty until a specific archetype needs its own look. Then add exactly the row you need, and set only the fields that differ. A shotgun pellet typically wants a smaller, shorter-lived mark; a slug wants a larger, longer-lived crater.
Surface
Key types
IHitDecalService
interface
The single entry point. Delivery sites call Spawn once per impact and the service handles resolution, pooling, and lifetime. Resolved through FoundryServiceRegistry and cached in Awake.
- void Spawn(in HitImpactContext context)
- void WarmupAll()
HitImpactContext
struct
Everything an impact needs, unified across callers that arrive with a RaycastHit, a Collider, or just a point and normal. Passed by in so it stays on the stack.
- Vector3 Point; Vector3 Normal; Collider HitCollider;
- SurfaceClassDefinition SurfaceOverride; float DamageMagnitude; string ImpactClassId;
- static HitImpactContext FromRaycast(RaycastHit hit, float damageMagnitude = 0f, string impactClassId = null)
ISurfaceClassResolver
interface
Turns a hit context into a surface. Walks override, marker, tag, material name, and finally the registry default; returns null only when the registry is empty.
- SurfaceClassDefinition Resolve(in HitImpactContext context)
ISurfaceClassRegistry
interface
The project-wide surface catalogue. Holds every registered definition in order, names the fallback, and answers the two pattern queries the resolver uses.
- IReadOnlyList<SurfaceClassDefinition> All { get; }
- SurfaceClassDefinition Default { get; }
- void Register(SurfaceClassDefinition definition)
- SurfaceClassDefinition GetById(string surfaceId)
- SurfaceClassDefinition MatchMaterialName(string materialName)
- SurfaceClassDefinition MatchGameObjectTag(string tag)
HitDecalService
service
The default implementation, constructed with a resolver, a registry, and the foundation pool service. Folds the surface and its impact-class override into one spawn, then publishes the audio cue.
- void Spawn(in HitImpactContext context)
- void WarmupAll()
SurfaceClassRegistry
class
Plain C# registry backed by an ordered list and a case-insensitive id dictionary. Duplicate ids keep the first registration and log a warning; blank ids are refused. Rejects "Untagged" as a tag match.
- void SetDefault(SurfaceClassDefinition definition)
- void Register(SurfaceClassDefinition definition)
SurfaceClassResolver
class
The chain walker. Marker discovery is an allocation-free upward walk; the material path is entered only when no marker or tag claimed the collider, so a well-authored scene rarely pays for it.
- SurfaceClassDefinition Resolve(in HitImpactContext context)
ImpactClass
class
The canonical impact-class id constants: bullet, buckshot, slug, beam, plasma, explosive, and melee. Strings rather than an enum so a project adds its own archetypes without recompiling the package.
- const string Bullet = "bullet"
- const string Buckshot = "buckshot"
- const string Slug = "slug"
- const string Beam = "beam"
- const string Plasma = "plasma"
- const string Explosive = "explosive"
- const string Melee = "melee"
HitImpactAudioCueEvent
struct
The event-bus payload published when a resolved surface declares an audio cue. Carries the cue id, surface id, impact class id, point, and normal.
- string CueId; string SurfaceId; string ImpactClassId;
- Vector3 Point; Vector3 Normal;
PooledHitDecal
component
Goes on every decal prefab. Owns its own lifetime, fades alpha over the last twenty percent of the window through a MaterialPropertyBlock, and returns itself to the pool. Implements IPooledLifecycle.
- ZOA/VFX/Pooled Hit Decal
- void Configure(float lifetimeSeconds, float decalSize)
- void OnSpawnedFromPool()
- void OnReturnedToPool()
PooledParticleAutoDespawn
component
Lifecycle helper for one-shot particle prefabs that do not return themselves. Replays its systems on spawn and despawns once nothing is alive and the window has elapsed. Added automatically when a particle prefab lacks it.
- ZOA/VFX/Pooled Particle Auto Despawn
- void Configure(float lifetimeSeconds)
Surface
Authoring assets
SurfaceClassDefinition
asset
One semantic surface and the response it produces: decal, particle, audio cue, pool budget, match patterns, decal size and lifetime, and a sparse impact-class override table. Adding a surface never requires a code change.
- ZOA/VFX/Surface Class
- string SurfaceId { get; }
- GameObject DecalPrefab { get; } GameObject ParticlePrefab { get; }
- string ImpactAudioCue { get; }
- int InitialPoolSize { get; } int MaxPoolSize { get; }
- IReadOnlyList<string> MaterialNamePatterns { get; }
- float DecalLifetimeSeconds { get; } float DecalSize { get; }
- IReadOnlyList<ImpactClassOverride> ImpactClassOverrides { get; }
- ImpactClassOverride TryGetOverride(string impactClassId)
- bool MatchesMaterialName(string materialName)
- bool MatchesGameObjectTag(string tag)
ImpactClassOverride
asset
One row in a surface's override table, serialized inline rather than as its own asset. Every field is optional: nulls and default multipliers fall through to the surface.
- string ImpactClassId { get; }
- GameObject DecalPrefab { get; } GameObject ParticlePrefab { get; }
- float DecalSizeMultiplier { get; } float DecalLifetimeMultiplier { get; }
- string AudioCueOverride { get; }
SurfaceMarker
component
Declares the surface class for a collider and its children explicitly, beating tag and material-name matching. Nearest marker in the parent chain wins, so a coarse marker on a prefab root coexists with fine ones on children.
- ZOA/VFX/Surface Marker
- SurfaceClassDefinition Surface { get; }
VfxSceneInstaller
component
Builds the registry from an inspector list of surfaces, constructs the resolver and service, and registers all three against FoundryServiceRegistry. Runs at execution order -9600, after the pool installer at -9700 and before ordinary consumers.
- ZOA/VFX/VFX Scene Installer
- void RegisterAdditional(SurfaceClassDefinition definition)
Usage
Examples
using UnityEngine;
using ZOA.Messaging;
using ZOA.Vfx.Core;
public sealed class HitscanDelivery : MonoBehaviour
{
private IHitDecalService _impacts;
private void Awake()
{
// Null resolution is survivable: the scene runs without impact
// feedback rather than throwing inside the firing path.
FoundryServiceRegistry.TryResolve<IHitDecalService>(out _impacts);
}
public void Fire(Ray ray, float range, float damage, string impactClassId)
{
if (!Physics.Raycast(ray, out var hit, range)) return;
_impacts?.Spawn(HitImpactContext.FromRaycast(hit, damage, impactClassId));
}
}using UnityEngine;
using ZOA.Vfx.Core;
public sealed class BodyPartDamageReceiver : MonoBehaviour
{
[SerializeField] private SurfaceClassDefinition _fleshSurface;
public void ApplyHit(IHitDecalService impacts, Vector3 point, Vector3 normal, float damage)
{
// SurfaceOverride short-circuits marker, tag, and material matching.
var context = new HitImpactContext(
point,
normal,
hitCollider: null,
surfaceOverride: _fleshSurface,
damageMagnitude: damage,
impactClassId: ImpactClass.Bullet);
impacts.Spawn(in context);
}
}using System;
using UnityEngine;
using ZOA.Messaging;
using ZOA.Vfx.Core;
public sealed class ImpactAudioListener : MonoBehaviour
{
private IDisposable _subscription;
private void OnEnable()
{
var bus = FoundryServiceRegistry.Get<IFoundryEventBus>();
_subscription = bus.Subscribe<HitImpactAudioCueEvent>(OnImpactCue);
}
private void OnDisable() => _subscription?.Dispose();
private void OnImpactCue(HitImpactAudioCueEvent evt)
{
// evt.CueId, evt.SurfaceId, evt.ImpactClassId, evt.Point, evt.Normal
// The VFX package never touches an AudioSource itself.
}
}using UnityEngine;
using ZOA.Vfx.Core;
using ZOA.Vfx.Unity.Installers;
public sealed class BiomeSurfacePack : MonoBehaviour
{
[SerializeField] private VfxSceneInstaller _vfx;
[SerializeField] private SurfaceClassDefinition[] _biomeSurfaces;
private void Start()
{
foreach (var surface in _biomeSurfaces)
_vfx.RegisterAdditional(surface);
}
}Tooling
Editor tools
Ensure Default Surfaces
Tools > ZOA > Advanced > Generate > VFX > Bundled > Ensure Default Surfaces
Materialises the baseline catalogue under Assets/ZOA/Bundled/Vfx/: five surfaces (Concrete, Metal, Wood, Flesh, Dirt), four decal prefab variants each for the bullet, buckshot, slug, and melee impact classes, a tinted material per variant, and the ImpactClassOverride rows wiring them onto the surfaces. Re-running never modifies an existing material, so author-supplied art survives a regenerate; the override wiring is rebuilt every run so adding an impact class is a re-run away.
Repair Decal Materials
Tools > ZOA > Advanced > Maintain > VFX > Bundled > Repair Decal Materials
Full texture-driven repair. Walks every PNG under Decals/Textures/, deduplicating Unity's auto-rename suffixes, reinstates missing materials and prefabs from their canonical names, switches the shader to Shader Graphs/Decal, wires the texture, and resets the base colour. Use it when decals render pink or when assets go missing after a sync.
Rewire Decal Textures
Tools > ZOA > Advanced > Maintain > VFX > Bundled > Rewire Decal Textures
The lighter pass. Only re-binds textures from Decals/Textures/ onto materials that are already on the right shader. Non-destructive when the material state is already correct, and safe to re-run.
Read this
Notes and caveats
See also