Skip to content

Low-Level Rendering (RHI)

The Render Hardware Interface (RHI) is the engine’s thin abstraction over Vulkan: memory, textures, pipelines, command lists, and submission. The whole RHI is exposed to C# as a direct, 1:1 binding, so a script can allocate GPU memory, build a pipeline, record commands, and dispatch work on the GPU.

This is the lowest-level surface in the scripting API. Every call is a thin pointer into the matching native function, handles and descriptors cross the boundary by value with no marshalling, and the command recorders skip the GC transition, so the C# RHI runs at the speed of the C++ RHI.

The RHI surface in C# is deliberately scoped to the operations a script may safely perform. The device, the per-frame loop, and the window swapchain are owned by the engine and are not on this surface. There is no device create or destroy, no frame tick, no present, and no device-wide stall. A script creates resources, records and submits work, and waits on its own submissions with a timeline semaphore.

You driveThe engine owns
GPU memory, textures, pipelines, semaphores, the bindless heapThe GPU device (create, destroy)
Command list recording and submissionThe per-frame loop and frame pacing
Barriers and per-submission synchronizationThe window swapchain and present
Device and memory introspectionVsync and the backbuffer

Everything lives in the LuminaSharp.Rendering namespace, across two static classes.

  • RHI is the core surface: memory, resources, the texture heap, command lists, submission, the Cmd* recorders, and introspection. Call sites read RHI.Malloc(...), RHI.CreateTexture(...), RHI.CmdDispatch(...), mirroring the C++ RHI:: functions.
  • RHICore is the runtime support layer: the global texture and sampler heap, the per-frame transient ring, deferred frees, and pipeline creation from the engine shader library by name.
using LuminaSharp.Rendering;

Resources are referenced by opaque handles, not objects. Each handle (FTextureH, FPipelineH, FTextureHeapH, FSemaphoreH, FDepthStencilH, FCmdListH) is a single 8-byte value you copy freely. A handle is just a number, so pass it around as you like.

FTextureH Texture = RHI.CreateTexture(Desc);
if (Texture.IsValid) // zero is the invalid handle
{
// ...
RHI.FreeH(Texture); // you free what you create
}

FreeH is overloaded for every handle type. You own the lifetime of anything you create, so free it when you are done, the same as delete in C++.

GPUPtr is a device-addressable GPU memory pointer (a 64-bit device address). Allocate it with RHI.Malloc, map it to a CPU address with RHI.ToHost, and release it with RHI.Free. GPUPtr supports byte-offset arithmetic, and zero is the null pointer.

GPUPtr Buffer = RHI.Malloc(1024, EMemoryType.CPUWrite);
unsafe
{
float* Cpu = (float*)RHI.ToHost(Buffer); // CPU-visible address
Cpu[0] = 1.0f;
}
RHI.Free(Buffer);

The memory type decides where the allocation lives. Both CPU-visible types are mapped and fully readable and writable from the CPU: the name is a hint about the dominant access pattern, not a restriction.

EMemoryTypeCPU accessTuned forToHost mappable
CPUWrite (default)Read and writeCPU writes and fast GPU reads (uploads, args). Lives in device-local ReBAR memory, so CPU reads work but are slow (uncached).Yes
CPUReadRead and writeCPU readback. Lives in cached host memory, so CPU reads are fast; the GPU reaches it across PCIe.Yes
GPUOnlyNoneGPU-only working data. Fastest for the GPU, device-local, not mapped.No

Malloc(Size) defaults to 16-byte aligned CPUWrite, which is read and write: you can both fill it from the CPU and read it back, so the default is a fine choice when you do not want to think about the distinction. The memory type and alignment are optional arguments, so pass Malloc(Size, EMemoryType.CPURead) when the CPU reads a lot of GPU-produced data and you want those reads cached, or give an explicit alignment with Malloc(Size, alignment, type). Calling ToHost on GPUOnly memory is invalid: to fill device-local memory, upload through a CPU-visible staging allocation and a copy command.

The RHI uses a single, uniform argument model for every draw and dispatch. Each command takes one GPUPtr (named DrawArgs) that points at a small struct in GPU memory. The engine binds that pointer as the shader’s only push constant, and the shader reads it back with GetArgs<T>().

In a shader (.slang), include the RHI conventions and declare the args struct:

#include "Includes/GlobalRHI.slang"
struct FMyArgs
{
float* Input; // a GPU device address (a buffer)
float* Output;
uint Count;
};
// GetArgs<T>() returns the struct the dispatch pointed at.
FMyArgs* Args = GetArgs<FMyArgs>();

On the C# side you build a byte-identical struct, fill it (including the device addresses of any buffers it references), and pass its GPUPtr to the dispatch or draw call. The fields line up by offset, so a shader float* matches a C# GPUPtr, and a shader uint matches a C# uint.

This is the same args model the engine’s own passes use, so anything you can express in an engine shader works identically from a script.

Textures and samplers are not bound one at a time. They live in a single global bindless heap, and a shader indexes them by an integer slot. RHICore exposes the engine’s global heap:

FTextureHeapH Heap = RHICore.GetGlobalHeap();
uint TexSlot = RHI.HeapWriteTexture(Heap, Texture); // sampled-image slot
uint UavSlot = RHI.HeapWriteRWTexture(Heap, Texture, 0); // storage-image slot (mip 0)
uint SmpSlot = RHI.HeapWriteSampler(Heap, FSamplerDesc.LinearWrap);

Each HeapWrite* returns the slot index. You pass that integer to your shader (through the args struct), and the shader reads the resource from the matching global array:

float4 Color = SampleTexture2D(Args.TexSlot, SAMPLER_LINEAR_WRAP, UV);
gRWTextures2D[Args.UavSlot][Texel] = Color;

Release a slot with HeapFreeTexture, HeapFreeRWTexture, or HeapFreeSampler when the resource goes away. The global heap is pre-populated with a set of stock samplers, so most shaders never create their own:

Stock sampler slotConstant in GlobalRHI.slang
0SAMPLER_LINEAR_WRAP
1SAMPLER_LINEAR_CLAMP
2SAMPLER_LINEAR_MIRROR
3SAMPLER_POINT_WRAP
4SAMPLER_POINT_CLAMP
5SAMPLER_ANISO_WRAP
6SAMPLER_ANISO_CLAMP

For a compute pass that uses the heap, bind it once on the command list with RHI.CmdSetTextureHeap(CL, Heap) before dispatching.

GPU work is recorded into a command list and then submitted to a queue. Open a list with RHI.OpenCommandList, record Cmd* calls into it, and submit it.

FCmdListH CL = RHI.OpenCommandList(EQueueType.Compute);
RHI.CmdSetPipeline(CL, Pipeline);
RHI.CmdDispatch(CL, ArgsPtr, GroupsX, 1, 1);
RHI.Submit(CL, EQueueType.Compute);

There are three queues, selected by EQueueType: Graphics (the default), Compute, and Transfer. Command-list recording is per-thread and lock-free, so you can build lists on worker threads; creation and submission are the synchronized operations.

To wait for work to finish, signal a timeline semaphore on submit and wait on it. A timeline semaphore is a monotonically increasing 64-bit counter: RHI.CreateSemaphore(start) makes one, the submit signals it to a value, and RHI.WaitSemaphore(sem, value) blocks the calling thread until the GPU reaches that value.

FSemaphoreH Done = RHI.CreateSemaphore(0);
FSemaphoreInfo[] Signal = { new FSemaphoreInfo(Done, 1, EStageFlags.Compute) };
RHI.Submit(EQueueType.Compute, new[] { CL }, default, Signal);
RHI.WaitSemaphore(Done, 1); // block until the GPU signals 1
RHI.FreeH(Done);

The same overload takes a span of waits, so one submission can wait on another’s semaphore to chain passes across queues.

The GPU runs work out of order unless you tell it not to. A barrier orders one set of pipeline stages before another so that writes are visible to later reads. Record one with RHI.CmdBarrier(CL, before, after) using EStageFlags, or use the named helpers in RHI.Barriers for the common cases.

HelperOrders
RHI.Barriers.ComputeToAll(CL)Compute writes before any later stage reads them.
RHI.Barriers.RasterToRead(CL)Color or depth output before a later sample.
RHI.Barriers.RasterToRaster(CL)One raster pass before the next.
RHI.Barriers.AllToTransfer(CL) / TransferToAll(CL)Around a copy or blit.

To read GPU results back on the CPU, barrier from the producing stage to EStageFlags.Host:

RHI.CmdBarrier(CL, EStageFlags.Compute, EStageFlags.Host);

Worked example: dispatch a compute shader and read back the results

Section titled “Worked example: dispatch a compute shader and read back the results”

This runs a compute shader that doubles every number in a buffer, then reads the results back to the CPU. It is the smallest end-to-end RHI program: a pipeline, two buffers, an args struct, a dispatch, and a semaphore wait.

Place a DoubleNumbers.slang in the engine shaders directory so the shader library can compile it by name. The args struct holds the two buffer addresses and the element count.

#include "Includes/GlobalRHI.slang"
struct FDoubleArgs
{
float* Input;
float* Output;
uint Count;
};
[shader("compute")]
[numthreads(64, 1, 1)]
void ComputeMain(uint3 Tid : SV_DispatchThreadID)
{
FDoubleArgs* Args = GetArgs<FDoubleArgs>();
uint Index = Tid.x;
if (Index >= Args.Count)
return;
Args.Output[Index] = Args.Input[Index] * 2.0;
}

The C# DoubleArgs struct mirrors the shader struct field for field. A shader float* is a device address, so it maps to a C# GPUPtr.

using System.Runtime.InteropServices;
using LuminaSharp.Rendering;
[StructLayout(LayoutKind.Sequential)]
struct DoubleArgs
{
public GPUPtr Input;
public GPUPtr Output;
public uint Count;
}
public static unsafe float[] DoubleOnGPU(float[] Values)
{
int Count = Values.Length;
ulong Bytes = (ulong)(Count * sizeof(float));
// 1. Pipeline, compiled and cached from the engine shader library by name.
FPipelineH Pipeline = RHICore.CreateComputePipeline("DoubleNumbers.slang");
// 2. Buffers. Both are CPU-visible (read + write). CPUWrite is the default,
// tuned for CPU writes and fast GPU reads; CPURead is tuned for the CPU
// reading the GPU's results back (cached reads). Either type can do both.
GPUPtr Input = RHI.Malloc(Bytes, EMemoryType.CPUWrite);
GPUPtr Output = RHI.Malloc(Bytes, EMemoryType.CPURead);
// Fill the input from the managed array.
float* In = (float*)RHI.ToHost(Input);
for (int i = 0; i < Count; i++)
{
In[i] = Values[i];
}
// 3. Args struct in GPU memory, holding the two buffer addresses + the count.
// No type given, so this uses the default: CPUWrite, which is read + write.
GPUPtr ArgsMem = RHI.Malloc((ulong)sizeof(DoubleArgs));
DoubleArgs* Args = (DoubleArgs*)RHI.ToHost(ArgsMem);
Args->Input = Input;
Args->Output = Output;
Args->Count = (uint)Count;
// 4. Record: bind the global heap (every pipeline's layout includes it),
// set the pipeline, dispatch one thread per element (64 per group),
// then barrier the compute writes so the CPU can read them.
FCmdListH CL = RHI.OpenCommandList(EQueueType.Compute);
RHI.CmdSetTextureHeap(CL, RHICore.GetGlobalHeap());
RHI.CmdSetPipeline(CL, Pipeline);
RHI.CmdDispatch(CL, ArgsMem, (uint)((Count + 63) / 64), 1, 1);
RHI.CmdBarrier(CL, EStageFlags.Compute, EStageFlags.Host);
// 5. Submit, signalling a timeline semaphore, then wait for the GPU.
FSemaphoreH Done = RHI.CreateSemaphore(0);
FSemaphoreInfo[] Signal = { new FSemaphoreInfo(Done, 1, EStageFlags.Compute) };
RHI.Submit(EQueueType.Compute, new[] { CL }, default, Signal);
RHI.WaitSemaphore(Done, 1);
// 6. Read the results back.
float[] Result = new float[Count];
float* Out = (float*)RHI.ToHost(Output);
for (int i = 0; i < Count; i++)
{
Result[i] = Out[i]; // Values[i] * 2
}
// 7. Release everything this function created.
RHI.Free(Input);
RHI.Free(Output);
RHI.Free(ArgsMem);
RHI.FreeH(Pipeline);
RHI.FreeH(Done);
return Result;
}

That is the full pattern. Every other RHI workload is a variation on it: more buffers, a graphics pipeline and a render pass instead of a dispatch, or the results left on the GPU (in GPUOnly memory) for a later pass to consume rather than read back.

A pipeline is the compiled shader plus its fixed-function state. Build a compute or graphics pipeline one of two ways.

By name, from the engine shader library (the path the example uses). The engine compiles the named .slang through its Slang pipeline and caches the result, so this is the simplest path for shaders the engine knows about.

FPipelineH Compute = RHICore.CreateComputePipeline("DoubleNumbers.slang");
FPipelineH Graphics = RHICore.CreateGraphicsPipeline(
"MyVertex.slang", "MyPixel.slang", FRasterDesc.Default, ColorTargets);

From compiled bytecode, when you have precompiled SPIR-V to hand. RHI takes the bytecode and the entry-point name directly:

FPipelineH Pipeline = RHI.CreateComputePipeline(SpirvBytes, "ComputeMain", default);

Both compute overloads accept a span of FSpecializationConstant, which bakes constants into the shader at pipeline-build time (a loop count, a feature toggle) so the compiler can fold them. Build them with the typed helpers:

FSpecializationConstant[] Constants =
{
FSpecializationConstant.UInt(0, 256),
FSpecializationConstant.Bool(1, true),
};

Free a pipeline with RHI.FreeH(Pipeline) when you are done with it.

Mesh shaders replace the vertex and index pipeline with two compute-like stages: an optional task (amplification) stage, and a mesh stage that emits a small batch of vertices and primitives per workgroup. There is no vertex buffer and no index buffer; the mesh stage produces geometry directly. This suits GPU-driven workloads like meshlet rendering and procedural geometry.

Mesh shaders need recent hardware (NVIDIA Turing and later, AMD RDNA2 and later) and the VK_EXT_mesh_shader device extension, so always check support first:

if (!RHI.SupportsMeshShaders())
{
return; // fall back to a vertex pipeline, or skip the effect
}

CreateMeshShaderPipeline also returns an invalid handle when mesh shaders are unsupported, so a guarded call is safe either way.

Build a mesh pipeline from compiled bytecode, the same way you build a graphics pipeline from bytecode. The task stage is optional: pass an empty TaskCode (and a "" entry) for a mesh-only pipeline, which is the common case. The fragment stage, raster state, and color targets behave exactly as for a graphics pipeline.

// Mesh-only (no task stage): mesh + fragment.
FPipelineH Pipeline = RHI.CreateMeshShaderPipeline(
MeshSpirv, "MeshMain",
PixelSpirv, "PixelMain",
FRasterDesc.Default,
ColorTargets,
Constants);
// With a task (amplification) stage, use the full overload:
FPipelineH WithTask = RHI.CreateMeshShaderPipeline(
TaskSpirv, "TaskMain",
MeshSpirv, "MeshMain",
PixelSpirv, "PixelMain",
FRasterDesc.Default, ColorTargets, Constants);

Unlike compute and graphics pipelines, there is no named-shader-library overload for mesh pipelines, so supply the compiled SPIR-V yourself.

A mesh draw is a raster draw, so record it inside a render pass (see below) with the mesh pipeline bound. You launch a grid of workgroups like a compute dispatch. The args model is identical to every other draw: DrawArgs is a GPUPtr to your shader’s argument struct, read back in the shader with GetArgs<T>().

RHI.CmdSetPipeline(CL, Pipeline);
RHI.CmdDrawMeshTasks(CL, ArgsPtr, GroupCountX, 1, 1);

For GPU-driven counts, drive the workgroup grid from a buffer the GPU filled. The indirect buffers hold FDrawMeshTasksIndirectArguments records (a uint3 workgroup count, mirroring VkDrawMeshTasksIndirectCommandEXT).

CallLaunches
CmdDrawMeshTasks(CL, args, x, y, z)A fixed x * y * z grid of workgroups.
CmdDrawMeshTasksIndirect(CL, args, buffer, offset, drawCount, stride)drawCount draws, each grid read from buffer.
CmdDrawMeshTasksIndirectCount(CL, args, buffer, offset, countBuffer, countOffset, maxDraws, stride)As above, with the draw count itself read from countBuffer (capped at maxDraws).

When you barrier compute or transfer writes before a mesh draw reads them, the mesh and task stages are already covered by RHI.Barriers.ComputeToAll and RasterToRead. Name them explicitly with EStageFlags.MeshShader and EStageFlags.TaskShader when you build a barrier by hand.

Create a texture from an FTextureDesc. The Texture2D helper fills the common case; set the EImageUsageFlags for how the texture will be used.

FTextureH Target = RHI.CreateTexture(
FTextureDesc.Texture2D(512, 512, EFormat.RGBA16_FLOAT,
EImageUsageFlags.Storage | EImageUsageFlags.Sampled));
EImageUsageFlagsAllows
SampledSampling in a shader (a heap sampled slot).
StorageRead/write in a shader (a heap storage slot).
ColorAttachment / DepthAttachmentRendering into it in a render pass.
TransferSrc / TransferDstCopy and blit source or destination.

To use a texture in a shader, register it in the bindless heap (above) and pass the returned slot through your args. To render into one, list it as an attachment in RHI.CmdBeginRenderPass. RHI.CreateTexture(Desc) lets the engine manage the backing memory; the overload taking a GPUPtr places the texture on memory you allocated.

Raster work (a graphics or mesh pipeline) records inside a render pass that targets textures you created with ColorAttachment or DepthAttachment usage. A script renders into its own textures this way; it cannot target the scene view or the swapchain (see Custom draws).

Begin a pass with the attachments to render into, set the viewport, bind a pipeline, draw, then end the pass.

FRenderAttachment[] Color = { FRenderAttachment.Clear(Target, 0, 0, 0, 1) };
RHI.CmdBeginRenderPass(CL, Color, new FUIntVector2(512, 512)); // color + render area
RHI.CmdSetViewport(CL, FRect.Size(512, 512));
RHI.CmdSetScissor(CL, FRect.Size(512, 512));
RHI.CmdSetPipeline(CL, Pipeline);
RHI.CmdDraw(CL, ArgsPtr, vertexCount: 3, instanceCount: 1, firstVertex: 0, firstInstance: 0);
RHI.CmdEndRenderPass(CL);

An attachment names a texture and how to treat its existing contents. Pass a depth attachment to CmdBeginRenderPass for depth testing.

FRenderAttachmentDoes
FRenderAttachment.Clear(tex, r, g, b, a)Clear to a color first, then store.
FRenderAttachment.Load(tex)Keep the existing contents, then store.

The draw family all take a DrawArgs GPUPtr for the shader args, like a dispatch:

CallDraws
CmdDraw(CL, args, vertexCount, instanceCount, firstVertex, firstInstance)Non-indexed.
CmdDrawIndexed(CL, indexBuffer, indexOffset, args, indexCount, instanceCount, firstIndex, vertexOffset, firstInstance)Indexed.
CmdDrawIndirect(CL, args, offset, drawCount, stride)Args from a buffer of FDrawIndirectArguments.
CmdDrawIndexedIndirect(CL, args, offset, drawCount, stride)Indexed, args from a buffer.
CmdDrawMeshTasks*Mesh shading (above).

Set fixed-function state on the command list before drawing:

CallSets
CmdSetViewport(CL, rect) / CmdSetScissor(CL, rect)Viewport / scissor rect (an FRect).
CmdSetCullMode(CL, mode) / CmdSetFrontFace(CL, face)Face culling and winding.
CmdSetLineWidth(CL, width)Line width for line topologies.
CmdSetIndexBuffer(CL, buffer, offset, type)The index buffer for indexed draws.
CmdSetDepthStencilState(CL, ds)A depth/stencil state object.

For depth or stencil, create a state object with RHI.CreateDepthStencil(FDepthStencilDesc), bind it with CmdSetDepthStencilState, give the pass a depth attachment, and free it with RHI.FreeH(depthStencil) when done.

For small, short-lived per-frame data (args structs, a handful of constants), RHICore.AllocTransient hands out CPU-writable, device-addressable memory from a ring that the engine recycles automatically each frame. It returns both the CPU pointer and the GPUPtr, so there is nothing to map and nothing to free.

FTransientAlloc Alloc = RHICore.AllocTransient((ulong)sizeof(DoubleArgs));
unsafe
{
DoubleArgs* Args = (DoubleArgs*)Alloc.Cpu;
Args->Count = 256;
// ...
}
RHI.CmdDispatch(CL, Alloc.Gpu, Groups, 1, 1);

The allocation is valid until its frame slot recycles, so use it within the frame and never hold it across frames. To free a longer-lived allocation safely once every in-flight frame has retired, use RHICore.DeferredFree(memory) instead of RHI.Free.

The RHI can report what GPU it is running on and how much memory is in use, which is handy for diagnostics and for sizing allocations.

GPUDeviceInfo Info = RHI.GetDeviceInfo();
// Info.Name "NVIDIA GeForce RTX 4080", Info.APIName "Vulkan 1.4.x", Info.IsDiscrete
var (Totals, Heaps) = RHI.GetGPUMemoryStats();
// Totals.TotalUsage / Totals.TotalBudget, plus per-heap detail in Heaps

Lumina’s renderer is deferred at the frame level. Gameplay runs first, across the update stages (frame start, pre-physics, physics, post-physics, frame end). Only at the end of the frame does the renderer gather (extract) the scene’s state and submit it, and it owns the scene’s render targets and the final present to the window. Every script has already run by the time the scene is drawn.

That ordering shapes what a script can and cannot do with the RHI.

You can run independent GPU work. Anything self-contained, a compute pass, a copy or blit, or a render pass into a texture you created, works from a script. You open a command list, record, submit to a queue, and wait on your own semaphore (the compute example above). It runs as its own GPU submission, on your schedule. This is the intended use of the RHI: GPU computation and offscreen work whose results you own.

You cannot inject a draw into the scene’s view. There is no render-extension or custom-pass hook in the renderer today, and the scene’s color and depth targets, plus the swapchain, are engine-owned and assembled at frame end after every script has run. A command list you submit is its own GPU work; it is not composited into the scene image, and you cannot draw to the backbuffer. Issuing “my own draw call into the world”, the way you would inside an engine render pass, is not possible from a script right now.

To put custom visuals in the world, use the channels that are wired into the renderer:

  • For lines, shapes, and text, World.Draw (debug drawing) feeds the engine’s own debug pass and shows up in the view. See The World API. (Dev and Debug builds.)
  • For anything heavier, treat the RHI as a producer: compute or render into a texture or buffer you own, then surface that result wherever the engine already lets you reference one. The RHI makes the data; the engine still owns compositing it into the final image.

A general “render into the scene view” extension point (a custom pass or view extension) is a future addition, not something the current API exposes.

The rest of the command surface copies and clears memory and textures, and annotates captures. Each records into a command list like the calls above.

Memory:

CallDoes
CmdMemcpy(CL, dest, source, size)GPU-to-GPU copy.
CmdMemset(CL, dest, size, value) / CmdMemzero(CL, dest, size)Fill with a value / zero.
CmdWriteMemory(CL, dest, data)Inline a CPU byte span into the stream and write it to dest.

Textures (use FTextureSlice.Full for the whole texture, or set its Mip, Layer, and Offset/Extent for a sub-region):

CallDoes
CmdCopyTexture(CL, src, srcSlice, dst, dstSlice)Copy a texture region.
CmdCopyMemoryToTexture(CL, src, rowLength, dst[, slice])Upload a buffer into a texture.
CmdCopyTextureToMemory(CL, src, slice, dst[, rowLength])Read a texture back into a buffer.
CmdBlitTexture(CL, src, srcSlice, dst, dstSlice[, filter])Scaled copy (resize, mip generation).
CmdResolveTexture(CL, src, dst)Resolve an MSAA texture to a single-sample one.
CmdClearTexture(CL, tex, r, g, b, a) / CmdClearTextureUInt(CL, tex, r, g, b, a)Clear to a float / uint color.

Debug markers group work in a GPU capture (RenderDoc, Nsight):

RHI.CmdBeginMarker(CL, "My pass");
// ... record ...
RHI.CmdEndMarker(CL);

To reuse a command list, RHI.ResetCommandList(CL) clears it for re-recording.

  • Free what you create. Handles and GPUPtr are not garbage collected. Every Create*, Malloc, and HeapWrite* has a matching free.
  • Record per-thread, submit synchronized. Build command lists on any thread, including worker threads. Submission and resource creation are the synchronized points.
  • Synchronize your own work. Wait on a timeline semaphore you signalled, not on the device. The device and the frame loop belong to the engine.
  • Barrier between dependent stages. The GPU will not order a write before a read for you.

If you are doing heavy CPU-side compute rather than GPU work, see Parallel Work. For the high-level, data-driven renderer that most projects use, see Rendering.