Pre-alphaFoundry is in development. Tools, APIs, and sample content may change.

Browse documentation
Presentationcom.zoa.vfx · v0.1.0

ZOA VFX

Resolve hit surfaces into decals, particles, and audio cues.

VFX takes a HitImpactContext and resolves the surface and impact class to decide which response to spawn. A shared surface definition lets multiple colliders use the same authored response.

Configure surface responses and pooling budgets in the editor. IHitDecalService uses Foundation’s prefab pool and publishes the associated audio cue; test the effects with the weapon delivery paths that will call it.

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.

Declared ZOA dependencies (2)

Referenced by ZOA manifests (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.

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Reference

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)

Reference

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

Spawning an impact from a hitscan tracecsharp
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));
    }
}
FromRaycast is the one-line shape most delivery sites want. The impact class id normally comes off the active weapon profile.
Skipping the chain with an explicit surfacecsharp
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);
    }
}
A caller that already knows the answer pays nothing for resolution. HitCollider may be null here because nothing downstream needs it.
Reacting to an impact audio cuecsharp
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.
    }
}
Extending the catalogue at runtimecsharp
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);
    }
}
RegisterAdditional is for extension packs that ship their own surfaces. Duplicate ids keep the first registration and warn.

Integration

Behavior to account for

See also