Custom Operators
Creating Custom Operators
Section titled “Creating 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.
Workflow
Section titled “Workflow”- Register —
ooc.sim_add_operator(ctx, name, fn)installs your callback and returns an operator handle. - Capture index — Store the operator index (
ooc.sim_operator_count(ctx) - 1) if you need to read parameters inside the callback. - Expose params (optional) —
ooc.sim_operator_set_params(ctx, op, params)publishes typed parameters for UI and introspection. - Modify at runtime — Use
ooc.sim_operator_param_set()to change parameters of any operator (including built-in ones).
Registration API
Section titled “Registration API”Method Signature
Section titled “Method Signature”local op = ooc.sim_add_operator(ctx, name, callback) --> operator_handle| Parameter | Type | Description |
|---|---|---|
ctx | context | Simulation context |
name | string | Display name for the operator |
callback | function | Function called each simulation step; receives context, returns true/false or a SimResult code |
Return Values
Section titled “Return Values”The callback should return:
trueor0— Success (SIM_RESULT_OK)falseor error code — Halt simulation
Accessing the Operator Index
Section titled “Accessing the Operator Index”The operator index is needed to read parameters inside callbacks. Capture it immediately after registration:
local my_op_idxlocal 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 trueend)my_op_idx = ooc.sim_operator_count(ctx) - 1Parameter Schema
Section titled “Parameter Schema”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" },})Supported Types
Section titled “Supported Types”| Type | Required Keys | Optional Keys |
|---|---|---|
float | name | default, min, max, description |
integer | name | default, min, max, description |
boolean | name | default, description |
enum | name, options | default, description |
field | name | default, description |
Runtime Parameter Modification
Section titled “Runtime Parameter Modification”-- Set a float/integer/boolean parameterooc.sim_operator_param_set(ctx, operator_index, "param_name", value)
-- Set an enum parameterooc.sim_operator_param_enum_set(ctx, operator_index, "param_name", "option_string")
-- Set a field parameterooc.sim_operator_param_field_set(ctx, operator_index, "param_name", field_handle)Example 1: Parameter Sweep
Section titled “Example 1: Parameter Sweep”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 ctxend
return build_context()Key points:
- The sweep operator modifies parameter
wavenumberon operator index0(the stimulus) - Uses a simple counter
tto track simulation steps sim_operator_param_setworks 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 ctxend
local context = build_context()return contextAccessing Profiler Data
Section titled “Accessing Profiler Data”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 trueend)Notes & Best Practices
Section titled “Notes & Best Practices”-
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, andfield: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(or0) on success. Returningfalseor 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.