Skip to content
Oakfield Operator Calculus Function Reference Site

Custom Operators

Custom operators let you extend the simulation with your own Lua callbacks. They execute alongside built-in operators in the scheduler and have full access to fields, parameters, and simulation state.


  1. Registerooc.sim_add_operator(ctx, name, fn) installs your callback and returns an operator handle.
  2. Capture index — Store the operator index (ooc.sim_operator_count(ctx) - 1) if you need to read parameters inside the callback.
  3. Expose params (optional)ooc.sim_operator_set_params(ctx, op, params) publishes typed parameters for UI and introspection.
  4. Modify at runtime — Use ooc.sim_operator_param_set() to change parameters of any operator (including built-in ones).

local op = ooc.sim_add_operator(ctx, name, callback) --> operator_handle
ParameterTypeDescription
ctxcontext Simulation context
namestring Display name for the operator
callbackfunction Function called each simulation step; receives context, returns true/false or a SimResult code

The callback should return:

  • true or 0 — Success (SIM_RESULT_OK)
  • false or error code — Halt simulation

The operator index is needed to read parameters inside callbacks. Capture it immediately after registration:

local my_op_idx
local my_op = ooc.sim_add_operator(ctx, "My Operator", function(c)
-- use my_op_idx here to read params
local value = ooc.sim_operator_param(c, my_op_idx, "gain")
return true
end)
my_op_idx = ooc.sim_operator_count(ctx) - 1

Expose typed parameters for UIs and introspection with sim_operator_set_params:

ooc.sim_operator_set_params(ctx, op, {
{ name = "gain", type = "float", default = 1.0, min = 0.0, max = 10.0 },
{ name = "field", type = "field", default = 0 },
{ name = "mode", type = "enum", options = {"fast", "accurate"}, default = "fast" },
})
TypeRequired KeysOptional Keys
floatnamedefault, min, max, description
integernamedefault, min, max, description
booleannamedefault, description
enumname, optionsdefault, description
fieldnamedefault, description
-- Set a float/integer/boolean parameter
ooc.sim_operator_param_set(ctx, operator_index, "param_name", value)
-- Set an enum parameter
ooc.sim_operator_param_enum_set(ctx, operator_index, "param_name", "option_string")
-- Set a field parameter
ooc.sim_operator_param_field_set(ctx, operator_index, "param_name", field_handle)

Dynamically modulate parameters of other operators over time. This pattern is useful for exploring parameter spaces or creating time-varying effects.

local function build_context()
local N = 768
local dt = 0.025
local ctx = ooc.sim_create()
ooc.sim_set_timestep(ctx, dt)
local field = ooc.sim_add_field(ctx, {N}, {
type = "complex_double",
fill = {0.0, 0.0}
})
-- Stimulus operator at index 0
ooc.sim_add_stimulus_operator(ctx, field, {
type = "stimulus_sine",
amplitude = 1.0,
wavenumber = 1,
omega = -0.5,
scale_by_dt = false
})
-- Custom sweep operator
local t = 0
local sweep_op = ooc.sim_add_operator(ctx, "Parameter Sweep", function(c)
t = t + 1
-- Slowly oscillate the wavenumber of the stimulus (operator index 0)
local sweep = math.sin(t * 0.00001) * 2.0
ooc.sim_operator_param_set(ctx, 0, "wavenumber", sweep)
return true
end)
return ctx
end
return build_context()

Key points:

  • The sweep operator modifies parameter wavenumber on operator index 0 (the stimulus)
  • Uses a simple counter t to track simulation steps
  • sim_operator_param_set works on any operator, not just custom ones

Example 2: Prime Sieve with Segmented Processing

Section titled “Example 2: Prime Sieve with Segmented Processing”

Implement complex control flow with internal state. This example shows a segmented sieve that processes primes in chunks, demonstrating field manipulation and multi-phase logic.

local function build_context()
local N = 64
local dt = 1.0
local segment_start = 0
local segment_end = segment_start + N - 1
local primes_per_tick = 1
local ctx = ooc.sim_create()
ooc.sim_set_timestep(ctx, dt)
-- -------- utilities --------
local function primes_up_to(limit)
local primes = {}
for n = 2, limit do
local is_prime = true
for _, p in ipairs(primes) do
if p * p > n then
break
end
if (n % p) == 0 then
is_prime = false
break
end
end
if is_prime then
primes[#primes + 1] = n
end
end
return primes
end
local function filter_primes_to_clear(base_primes, seg_end)
local out = {}
for _, p in ipairs(base_primes) do
if p * p > seg_end then
break
end
out[#out + 1] = p
end
return out
end
local function init_candidates(field)
local v = field:values()
for i = 1, #v do
local n = segment_start + (i - 1)
v[i] = (n >= 2) and 1.0 or 0.0
end
field:set_values(v)
end
-- p is broadcast as a full-length field (updated from controller)
local field_p = ooc.sim_add_field(ctx, {N}, {
type = "double",
fill = {2.0}
})
-- coord is computed: coord[i] = i + bias (bias updated per segment)
local field_coord = ooc.sim_add_field(ctx, {N}, {
type = "double",
fill = {0.0}
})
local coord_op = ooc.sim_add_coordinate_operator(ctx, field_coord, {
mode = "index",
normalize = "none",
gain = 1.0,
bias = segment_start,
scale_by_dt = false
})
-- Candidates ping-pong (compute primes_b from primes_a then copy back)
local primes_a = ooc.sim_add_field(ctx, {N}, {
type = "double",
fill = {0.0}
})
local primes_b = ooc.sim_add_field(ctx, {N}, {
type = "double",
fill = {0.0}
})
-- Scratch: reuse field_eq0 for both mod and eq (saves one field)
local field_eq0 = ooc.sim_add_field(ctx, {N}, {
type = "double",
fill = {0.0}
})
-- field_eq0 := coord % p
ooc.sim_add_elementwise_math_operator(ctx, field_coord, field_p, field_eq0, {
mode = "mod",
rhs_source = "field",
epsilon = 1.0e-6,
scale_by_dt = false
})
-- field_eq0 := (field_eq0 == 0)
ooc.sim_add_elementwise_math_operator(ctx, field_eq0, nil, field_eq0, {
mode = "eq",
rhs_source = "constant",
rhs_constant = 0.0,
epsilon = 1.0e-6,
true_value = 1.0,
false_value = 0.0,
scale_by_dt = false
})
-- primes_b = invert(primes_a, eq0)
ooc.sim_add_mask_operator(ctx, primes_a, field_eq0, primes_b, {
mode = "invert",
threshold = 0.5,
feather = 0.0,
fill_value = 0.0,
scale_by_dt = false
})
-- -------- controller state --------
local base_primes = primes_up_to(100000)
local primes_to_clear = filter_primes_to_clear(base_primes, segment_end)
local stage_idx = 1
local have_pending_result = false
local last_p_used = 2
-- Patch: ensure p itself stays marked as candidate if it lies inside the segment.
local function patch_prime_itself(p)
if p < segment_start or p > segment_end then
return
end
local idx = (p - segment_start) + 1
local v = primes_a:values()
v[idx] = 1.0
primes_a:set_values(v)
end
local function reset_segment()
segment_end = segment_start + N - 1
primes_to_clear = filter_primes_to_clear(base_primes, segment_end)
stage_idx = 1
have_pending_result = false
ooc.sim_operator_param_set(ctx, 0, "bias", segment_start)
init_candidates(primes_a)
end
reset_segment()
-- -------- GUI tick operator --------
local controller = ooc.sim_add_operator(ctx, "Segmented Sieve Controller", function(c)
-- Phase 0: commit last tick's result so the next stage starts from it
if have_pending_result then
primes_a:set_values(primes_b:values())
have_pending_result = false
patch_prime_itself(last_p_used)
end
-- If finished segment, report using primes_a (the committed buffer)
if stage_idx > #primes_to_clear then
local v = primes_a:values()
local found = {}
for i = 1, #v do
if v[i] > 0.5 then
found[#found + 1] = segment_start + (i - 1)
end
end
ooc.log("segment %d..%d primes=%d (showing up to 16)", segment_start, segment_end, #found)
for i = 1, math.min(16, #found) do
ooc.log(" prime[%d]=%d", i, found[i])
end
segment_start = segment_start + N
reset_segment()
return true
end
-- Apply one (or a few) primes per tick
for _ = 1, primes_per_tick do
if stage_idx > #primes_to_clear then
break
end
local p = primes_to_clear[stage_idx]
last_p_used = p
-- Broadcast p to the field
local pv = field_p:values()
for i = 1, #pv do
pv[i] = p
end
field_p:set_values(pv)
stage_idx = stage_idx + 1
have_pending_result = true
end
return true
end)
return ctx
end
local context = build_context()
return context

Custom operators can query the profiler for performance monitoring:

local profiler_op = ooc.sim_add_operator(ctx, "Profiler", function(c)
if t % 1000 == 0 then
local entries, total_ms = ooc.sim_operator_profiler(ctx)
if entries then
for _, e in ipairs(entries) do
ooc.log("%-20s %.3f ms", e.name, e.inclusive_ms)
end
ooc.log("Total: %.3f ms", total_ms)
end
end
return true
end)

  • Performance: Keep Lua operators lightweight. Heavy per-element loops bottleneck CPU execution. For numerical work, use built-in operators that run on the GPU.

  • Write masks: The scheduler does not infer write masks for Lua operators. Avoid side effects outside the fields you explicitly manage.

  • Field access: Use field:values() to get a Lua table of values, and field:set_values(v) to write back. These copy data between Lua and the simulation.

  • Logging: Use ooc.log(format, ...) for printf-style debug output. Avoid excessive logging in tight loops.

  • Return values: Always return true (or 0) on success. Returning false or an error code halts the simulation.

  • Operator ordering: Operators execute in registration order. If your custom operator depends on another operator’s output, register it after.