Skip to content

Caching Operations

Artyom Romskiy edited this page Oct 9, 2026 · 2 revisions

Caching Operations

Iteration and generated query terminals return deferred operations. Building an operation does not run it; Invoke() does. Keep the returned operation when the same query and callback are used on every game update, then invoke it whenever that pass should run.

Build once, run on every update

A system can store an operation through IOperation:

using System;
using Delta.ECS;

public sealed class MovementSystem
{
    private readonly IOperation _move;

    public MovementSystem(World world)
    {
        Query movingUnits = world.WhereAll<Position, Velocity>();

        _move = world.ForEach(
            in movingUnits,
            static (ref Position position, in Velocity velocity) =>
            {
                position.X += velocity.X;
                position.Y += velocity.Y;
            });
    }

    public void Update() => _move.Invoke();
}

The operation is created in the system constructor and invoked from Update. Storing a value-type operation in an IOperation field boxes it once when assigned; later calls reuse that stored instance.

A generic system can keep the operation as a struct and invoke it through a generic constraint:

public static class TypedOperationExample
{
    private static void Run<TOperation>(ref TOperation operation)
        where TOperation : struct, IOperation => operation.Invoke();

    public static void RunTwice(World world, in Query movingUnits)
    {
        var move = world.ForEach(
            in movingUnits,
            static (ref Position position, in Velocity velocity) =>
            {
                position.X += velocity.X;
                position.Y += velocity.Y;
            });

        Run(ref move);
        Run(ref move);
    }
}

The generic call keeps the operation as a struct, without boxing. A local inferred with var also keeps its struct type. For a field in a non-generic class, use IOperation (which boxes once when assigned) or make the class generic.

Choose where mutable state lives

Functor operations keep a typed copy of the functor. Invoke() uses and updates that operation-owned copy, so its state carries across invocations:

var counter = new CountWounded();
var countWounded = world.ForEach(in healthChecks, ref counter);

countWounded.Invoke();
countWounded.Invoke();

Use Invoke(ref functor) when the caller should own and inspect the state instead:

countWounded.Invoke(ref counter);
Console.WriteLine(counter.Count);

Context-bearing operations follow the same rule: use the parameterless Invoke() for operation-owned context, or the matching Invoke overload to pass caller-owned context. A Where view keeps its predicate and predicate context between terminal invocations.

What an operation keeps live

An operation retains its query and its live query plan, not a frozen list of matching chunks. Each Invoke() checks that execution is valid and processes the entities that match at that time. Archetypes and chunks created after the operation was built are included when they match the query.

The operation does not rerun its builder or recreate the query expression on every update. Rebuild it when the query or the operation's configuration should change. A span of entities or dynamic ComponentId values supplied while building an operation is copied into the operation, so later changes to the source span do not alter the stored inputs.

For an open generic functor selected by ComponentId, the closed executor is resolved and cached on its first invocation; subsequent invocations reuse it. This is a separate cache from the operation itself.

Previous: Entity Component Iteration · Next: Component Type Visitors

Clone this wiki locally