Job system & performance

How work is split into small jobs to keep frame times under control on an embedded chip.

Why jobs?

Even a tiny 3D scene can overwhelm a straight, monolithic loop on an ESP32-S3. A job-based design helps to:

  • Split work across cores or across several frames.
  • Interrupt and resume long tasks when you hit time limits.
  • Measure and tune hot spots more easily.

Granularity and budgets

Jobs should be large enough to hide scheduling overhead, but small enough to be interruptible.

  • Examples of good job sizes: per-object culling, per-chunk rasterization, CPU-side animation for a group of entities.
  • Think in terms of a time budget (for example 10–12 ms for 3D) and keep your job queues consistent with that budget.

Integration with your scheduler

Pip3D does not force a specific threading model. You can integrate its jobs into:

  • A single main loop that processes a queue of jobs each frame.
  • Separate RTOS tasks with message queues or semaphores.
  • A custom cooperative scheduler built for your project.

Treat the job layer as a toolbox you adapt to your firmware rather than the other way around.

JobSystem API overview

The job system exposes a compact set of types and functions:

  • JobFunc – function pointer type void (*)(void*) for jobs.
  • Job – lightweight pair of func and userData stored in the internal queue.
  • JobSystem – static API that owns a single worker task and a bounded job queue.
  • useDualCore(bool enabled) / isDualCoreEnabled() – high-level helpers that turn the system on and off.

On ESP32-S3 the worker runs on the second core using FreeRTOS. On other platforms the same API falls back to synchronous execution so your code keeps working without threads.

Types and basic API

The core declarations look like this:

typedef void (*JobFunc)(void* userData);

struct Job
{
    JobFunc func;
    void*   userData;
};
class JobSystem
{
public:
    static bool init();
    static void shutdown();
    static bool submit(JobFunc func, void* userData = nullptr);
    static bool isEnabled();
private:
    static void workerLoop(void* param);
};
void useDualCore(bool enabled);
bool isDualCoreEnabled();

JobFunc. All jobs share the same signature: void (*)(void*). The function must be a free function or a static member – capturing lambdas and non-static members are not compatible.

Job. A small POD struct stored in a fixed-size ring buffer (32 entries). There are no per-job heap allocations, so the system is safe for tight real-time loops.

JobSystem::init / shutdown. On ESP32-S3 init() creates the worker task, mutex and counting semaphore, while shutdown() stops the worker and frees resources. Multiple calls are allowed; repeated init() after success simply re-enables the system.

JobSystem::submit. Attempts to enqueue a job. It returns false when the system is disabled, the function pointer is nullptr, the internal mutex is momentarily busy or the queue is full. This non-blocking design ensures the render loop is never stalled while waiting for the worker.

JobSystem::isEnabled. Reports whether the job system is currently active. Engine systems like physics and time-of-day logic use this to decide whether to take async paths.

useDualCore / isDualCoreEnabled. High-level switch you can call from your firmware. Enabling dual core internally calls JobSystem::init(), disabling calls JobSystem::shutdown(). The convenience wrapper isDualCoreEnabled() is what application-level code usually checks.

Execution model and platform behavior

Internally the engine maintains a single worker task and a bounded queue of pending jobs:

  • Fixed-size ring buffer (32 slots) with a pair of indices for push/pop.
  • Worker loop that waits on a semaphore and executes jobs one by one without additional allocations.
  • Submission is non-blocking: if the queue is temporarily full or locked you simply get false and can retry later or fall back to synchronous work.

On ESP32 (ARDUINO_ARCH_ESP32):

  • init() creates the FreeRTOS worker task pinned to the second core, a mutex and a counting semaphore.
  • Jobs are executed truly asynchronously on the worker core.

On other platforms:

  • init() always fails and keeps the system disabled.
  • submit() still supports direct synchronous execution: if func is not nullptr, it is called immediately on the calling thread and true is returned.
  • isEnabled() and isDualCoreEnabled() remain false, so higher-level code naturally falls back to single-threaded paths.

Typical usage patterns

A common way to use the job system is to enable dual-core execution during setup and then schedule medium-weight tasks from the main loop:

  • Call useDualCore(true) once during startup (the renderer also does this from Renderer::init).
  • For each background task prepare a small context struct with all required data and pass it as userData.
  • In the job function cast userData back, perform the work and write results in a way that is safe to read from the main thread (for example via an atomic flag or a double buffer).

Engine subsystems such as physics and the day/night controller use this pattern to offload heavy computations: they keep the render loop responsive while still taking advantage of the second core when available.