Skip to content

GPU backend

github-actions[bot] edited this page Oct 16, 2026 · 36 revisions

GPU backend

render/gpu.mojo. GpuRenderer runs the rasterizer as a kernel with one thread per pixel. It consumes the same RasterVertex list as the CPU rasterizer and produces the same image.

An icosahedron turns. Both backends draw this picture.

three.js has no equivalent. Mojo's GPU support is compute only, so this is a compute kernel and not a graphics pipeline.

Requirements

MAX 26.6.0 and an accelerator. See How to use the GPU backend. render/gpu.mojo is the only module that imports MAX.

Functions

Function Meaning
available() -> Bool Whether a GPU is present.
render_triangles(corners, width, height, background, mode, textures, lighting, fog, tone_mapping, exposure, lines, draws, scissor, points, cubes, backdrop, transmission, programs) -> Framebuffer One-shot: draw and read back.
flatten(corners) -> List[Float32] The corner buffer the kernel reads, one lane per varying.
flatten_into(corners, dest, workers) Write the same lanes to dest on workers threads. draw writes the corner buffer this way, straight into the device buffer's host mapping.
join_corners(frame, workers=0) -> List[RasterVertex] Frame.whole_corners(), joined on workers threads, or one for each logical core. Pass it to draw.
bin_triangles(corners, width, height, base, limit=BIN_LIMIT) -> List[Int32] Each 16 x 16 tile's triangles, in the frame's order. See Tile binning.
flatten_lights(lighting) -> List[Float32] The light buffer: the camera's position, the one direction toward it, its up axis, the ambient term, the light probes, then each directional, point, hemisphere, spot and rect area light, then the LTC tables when there is a rect area light, then the shadow maps, then the spot light maps.
flatten_fog(fog) -> List[Float32] The fog buffer, six floats. The kind crosses as a kernel argument.
flatten_programs(programs) -> List[Float32] Every node program, end to end, as it follows the fog in the fog buffer.
program_starts(programs) -> List[Int] Where each node program starts in the fog buffer.
flatten_textures(store) Every texture in one buffer, with a descriptor table. See Texture table.
triangle_state(corners) -> List[Int32] Texture, blend, material kind, emissive map and alpha map per triangle.

GpuRenderer

Hold one across frames. The device buffers survive between draws.

Member Meaning
GpuRenderer(width, height) Create the context and the buffers. Raises without a GPU.
set_textures(store, cubes=CubeTextureStore()) Upload every texture, then every cube texture's six faces and its PMREM after them, seven rows a cube. All or nothing.
draw(corners, background, mode, lighting, fog, tone_mapping, exposure, lines, draws, scissor, points, backdrop, line_width, transmission, programs) Rasterize into the device target. corners is join_corners(frame) or Frame.whole_corners(), for the frame from Renderer.prepare_frame. programs is Frame.programs from Renderer.prepare_frame: the node programs, with the frame's time and view. transmission is what Renderer.transmission_target returns, or an empty target when nothing transmits. backdrop is what Renderer.backdrop returns, or none: the scene's image background, painted before anything is drawn. scissor is a Rect the draw may touch, or none for the whole target. A pixel outside it is neither cleared nor drawn, so the target keeps it between draws; see Renderer. Pass Lighting(scene, visible=camera.visible_layers()) and FogView(scene.fog, view), the values Renderer.render uses. lines are two corners a segment, points are one corner a point, and draws is the order, all from Renderer.prepare_frame. An empty order draws every triangle, then every segment, then every point. The kernel tone maps each pixel as RenderTarget.resolve does.
read_back() -> Framebuffer Copy color and depth to the host.
read_back_target(type=UNSIGNED_BYTE_TARGET, outputs=color_only(), samples=0) -> RenderTarget Copy the light before the tone mapping, the normal, the depth and the data flag into a host target. With samples, resolve the samples on the device first. See Float render targets, Multiple render targets and Multisampled targets on the GPU.
read_back_layer(target, layer, level=0) Read back into one layer and one level of a layered target, with that image's type, outputs and samples.
copy_framebuffer_to_texture(texture, position=TexelPoint(0, 0, 0), level=0) read_back, then the host's copy into a texture.

draw checks every triangle's state, every segment's state, every point's state and every draw's run on the host before it launches. It checks every texture id, the alpha test, the tone mapping curve and the exposure the same way. The kernel cannot raise.

An alpha map must be LINEAR and IGNORED. draw asks that of the descriptors it uploaded, as the CPU asks it of the store.

set_textures builds the new buffers first and replaces the old ones together with the count. A failed upload leaves the previous upload whole.

Volume and array textures are validated before the upload changes device state. Their dimensions and remapped program addresses must fit Int32 and remain exact in Float32. An address that would round is rejected. Large aligned values can remain exact; the CPU packing tests check these bounds without large allocations.

An antialiased frame is drawn at Renderer.supersampled()'s size and shrunk with render.antialias.downsample. Pass that renderer's render_scale as the draw's line_width, or every line thins out when the frame is averaged down. The CPU renderer resolves in linear light instead, which the GPU cannot do while it returns bytes; see Renderer. A texture's anisotropy crosses in the descriptor table, and the kernel takes the same taps the host takes; see Textures.

Tile binning

The kernel tests a pixel only against the triangles of its 16 x 16 tile. draw makes these lists on the host with bin_triangles and puts them after the draw table in the draw buffer. The kernel already binds thirty-one arguments, the most Metal allows, so the lists cannot have a buffer of their own.

A triangle covers a pixel only when the pixel's sample is inside the snapped triangle. So a triangle goes in each tile that has a pixel whose sample is inside the triangle's snapped bounds. bin_triangles uses the same snap and sample functions as the kernel. A tile's list keeps the frame's order. So each pixel sees the same covering triangles, in the same order, as a walk over every triangle.

tests/test_gpu_layout.mojo checks each covered pixel of a triangle field against the bins. tests/test_gpu.mojo compares a field of thin, blended triangles on both backends.

A triangle with a corner that is not finite, or that is larger than 2^24 pixels, goes in every tile. The kernel then decides it as it did before. When the lists would hold more than BIN_LIMIT entries, 2^27, draw does not bin the frame. Each pixel then tests every triangle. Segments, points and splats are not binned.

Multisampled targets on the GPU

A multisampled target is drawn at the size of its sample grid and resolved by a second kernel. The rasterize kernel gains no argument. It binds thirty-one, which is the most Metal allows.

To draw one, prepare the frame with Renderer.multisampled(samples). Hold a GpuRenderer of that renderer's size, and pass its render_scale as the draw's line_width. Then call read_back_target(type, outputs, samples).

read_back_target launches resolve_kernel with one thread for each resolved pixel. The kernel reads the samples from the depth buffer's planes, where the rasterize kernel left them. It calls resolve_block, the function the host's RenderTarget.resolve_samples calls. It writes the result into a buffer in the same plane layout, and only that buffer comes back. So the two backends resolve with the same arithmetic, and tests/test_gpu.mojo compares them.

This layout was chosen for three reasons:

  • A resolve is a second launch after the draw, as a GPU's resolve is. So it takes its own seven arguments and leaves the rasterize kernel's thirty-one alone.
  • The samples already sit in the depth buffer's planes. A per-target sample buffer inside the rasterize kernel needs an argument or a second layout in an existing buffer, and neither buys a thing.
  • The resolved buffer lives for one read back. The draw's buffers keep their size, and a frame with no samples pays nothing.

The stencil is not read back. The rasterize kernel keeps it in a local number per pixel.

Parity with the CPU

Coverage is integer arithmetic and matches the CPU exactly. Shading is floating point and matches within one level per channel, because the device fuses multiply and add. tests/test_gpu.mojo holds both backends to those standards on hand-built triangles and on whole prepared scenes.

The kernel calls the same functions as the CPU for the fill rule, texture wrapping and texel blending. It shares the light falloff, the spot light's rim and the Blinn-Phong highlight. It shares the fog factor, the normal and depth packing, and the tone mapping curves too. See Why the CPU and GPU share code.

The state table carries STATE_PER_TRIANGLE entries per triangle, thirty-five at present. triangle_state writes them from the first corner, in this order:

Column Entry
0 to 6 The texture, the blend policy, the material kind, the emissive map, the alpha map, the gradient map and the matcap.
7 and 8 The table row of the env map's first face, or -1 for none, and the Combine value.
9 to 12 The roughness, metalness, normal and bump maps.
13 One if the shadows fall on the triangle, zero if not.
14 and 15 The packed depth, color and stencil state: RasterState.ops_word and RasterState.stencil_word.
16 and 17 The ambient occlusion map and the light map.
18 The specular map.
19 and 20 The transmission map and the thickness map.
21 and 22 The depth packing's value, and one if the fog veils the triangle, zero if not.
23 to 27 The sheen color, sheen roughness, iridescence, iridescence thickness and anisotropy maps. See Materials.
28 Where the triangle's node program starts in the fog buffer, or -1 for none.
29 to 33 The specular intensity, specular color, clearcoat, clearcoat roughness and clearcoat normal maps. See Materials.
34 The normal map type's value, STATE_NORMAL_MAP_TYPE. See Materials.

A triangle's TextureFrames ride its vertex lanes, from LANE_ENV_ROTATION. The first nine numbers turn the environment's lookup, then the refraction ratio, then nine numbers of the mesh's normal matrix for an object-space normal map. The kernel turns a direction with Basis3.turn and bends one with env_direction, the host's own functions. It reads an object-space normal map with object_space_normal, as the host does.

Each map column holds a texture id, or NO_TEXTURE for none. The STATE_ constants in render/gpu.mojo name every column. A TOON triangle's ramp is the gradient map column. The kernel reads the ramp's top row straight out of the texel buffer, with the host's own toon_index.

The depth, color and stencil state rides the triangle, segment and point tables as two integers, RasterState.ops_word and RasterState.stencil_word. The kernel unpacks them and calls test_fragment, the function the host's target calls. It keeps the stencil as one local number per pixel, cleared to zero at the start of the launch. See Materials.

The kernel tracks whether each pixel holds data rather than light, as the host's target does. It keeps the fog and the curve off those pixels.

A transmissive triangle reads the transmission target from the backdrop buffer. The kernel has no argument to spare, because Metal binds at most thirty-one and it has thirty-one. So draw writes the target after the backdrop's own pixels: a header of TRANSMISSION_HEADER floats, then the mip chain as floats. The header is the world-to-target matrix, the width, the height and the level count. flatten_transmission lays it out.

A node material reads its program from the fog buffer, after the fog's six floats. draw writes every program there, end to end, and grows the buffer to fit. The state column 28 holds where the triangle's program starts. The kernel runs run_nodes, the host's own interpreter, over a source that reads that buffer and samples with _sample_slot.

The kernel calls volume_refraction, the host's own function, over a source that reads the chain with _sample_at. The host draws the transmission pass: pass Renderer.transmission_target(scene, assets, camera) as draw's transmission.

The light buffer begins with three floats of camera position at LIGHTS_EYE, then three at LIGHTS_TOWARD. Those three hold the zero vector for a converging projection, and one unit direction for a parallel one. The kernel passes both to toward_eye_at, the host's own function. The camera's own up axis follows at LIGHTS_UP, for the frame a MATCAP surface is looked up in.

The scale every lit sum takes is at LIGHTS_SCALE and the count of rect area lights at LIGHTS_RECT_COUNT. The light probes' 27 coefficients follow at LIGHTS_PROBE; see Lights. The lights follow at LIGHTS_FIRST. The rect area lights come last among them, then the LTC tables when there is one, then the shadow maps, then the spot light maps. The spot profiles and the probe grid follow, and LIGHTS_GRID says where the grid begins. See Lights, Lights, Lights and Lighting addons.

Texture table

The texture table holds TABLE_COLUMNS entries per texture, twenty-one at present. Each map has its own sampler, transform and channel, and the table carries them. The lanes and the state columns do not change: a triangle names its maps by id, and each id has one row.

Column Entry
0 to 9 The byte offset, the width, the height, wrap_s, mag_filter, the color space, the level count, the alpha mode, the anisotropy and the texel type.
10 and 11 TABLE_WRAP_T and TABLE_MIN_FILTER: wrap_t and min_filter.
12 TABLE_FLIP_Y: one if v counts up from the bottom row, zero if not.
13 TABLE_MAPPING: the mapping's value. A cube's panorama row holds the cube's mapping.
14 to 19 From TABLE_PLACEMENT: the six numbers of Texture.placement, as their Float32 bits.
20 The channel: UV_CHANNEL_0 or UV_CHANNEL_1.

The kernel samples a row as the host samples the texture. It reads a named level through level_filter, a fractional one through plan_levels, and a row through row_coordinate. wrap_index wraps each axis by its own mode. See Textures.

A cube texture takes CUBE_ROWS rows, eight at present: its six faces, its PMREM and its panorama. A cube without a PMREM or a panorama has one white byte texel there. The kernel reads the panorama row's mapping. An equirectangular one means the cube samples its panorama at equirect_uv, and a refraction one means a basic, lambert or phong surface refracts.

_placement reads a row back as a UvPlacement. The kernel places each map's pair with UvPlacement.place, the function the host calls. So both backends sample each map at the same coordinate. The table is the cheapest exact layout. It adds seven numbers per texture, where a matrix per map per triangle adds 132 per triangle.

The kernel also adds the geometric roughness with geometry_roughness, the host's function. It interpolates the normal at the pixel one to the right and at the pixel one up, as the host does. LIGHTS_UP and LIGHTS_BACK turn the change into view space.

Post-processing on the GPU

GpuComposer runs an EffectComposer with the frame on the device. The frame stays on the device between passes. It crosses the bus only for a pass that has no kernel. examples/bloom.mojo draws those passes on the CPU. The device runs the same pixels.

var device = GpuComposer(renderer.width, renderer.height)
var image = device.render(composer, renderer, scene, assets, camera)

The device frame holds four floats per pixel, one data byte, the depth and the stencil. The four floats are premultiplied light, or the data itself, straight, where the pixel holds data, as a host frame holds them.

Before a pass that reads the frame as light, one kernel premultiplies the data pixels, and after it the same kernel stores them straight again. EffectComposer.run_step does the same on the host. render puts the cleared frame on the device once and reads it back once at the end. The host resolves it through no curve, as EffectComposer.render does.

Member Meaning
GpuComposer(width, height) Create the context and the device frame. Raises without a GPU.
render(composer, renderer, scene, assets, camera, delta_time=0.0) -> Framebuffer Run every enabled pass in order. Raises everything EffectComposer.render raises, and raises if the renderer is another size.
upload(frame) and download(frame) Copy a RenderTarget to the device frame and back.
round_trips How many passes of the last render ran on the host.
runs_on_device(kind) -> Bool Whether a kind of pass has a kernel.

These passes run as kernels:

  • Copy, blur, bloom, film, dot screen, sepia, vignette, luminosity, afterimage and output.
  • FXAA, glitch, halftone, clear, texture and LUT.
  • Bokeh. The host draws the depth, and the device blurs the light.
  • The fifteen shader effects, the cube texture, the save and the shader pass. A shader pass whose program reads a texture in the assets uses the fallback, because the device holds no textures.
  • God rays. The host draws the depth, and the device makes the mask, walks the rays and adds them.

These passes use the fallback. The composer reads the frame back, runs EffectComposer.run_step on the host, and puts the frame back:

  • Render, SSAA, TAA, SSAO, SAO, SSR, outline, mask, pixelated, GTAO and transition. Each draws the scene. The GPU rasterizer resolves to bytes, and a pass needs the light.
  • SMAA. Its three stages walk rows and columns of edges, and the port keeps them on the host.

A mask works on the device. Before each pass inside a mask, a kernel copies the frame aside. After the pass, a kernel puts back each pixel that inside_mask refuses.

Shared arithmetic

Each kernel calls the per-pixel function that the host pass calls. Examples are copy_pixel, blur_pixel, bloom_glow, fxaa_pixel, glitch_pixel, halftone_pixel, lut_pixel, effect_pixel, god_rays_generate_pixel and screen_pixel. A pass that reads its neighbors reads a LightView. The host builds one over a list, and a kernel builds one over a device buffer. Both read through the same tap and sample.

The LUT kernel reads a DecodedVolume through filter_volume, the function Data3DTexture.sample calls. The host decodes the table and uploads it for each LUT pass.

The host computes these values once per frame and passes them to the kernel:

  • The sine and the cosine of the dot screen's angle and of the glitch's shift.
  • The glitch's uniforms and its displacement map.
  • The texture pass's texel at each pixel center, which does not depend on the frame.

The afterimage's trail stays in the composer on the host. Both backends then share it, and reset clears it. The GPU composer uploads the trail and reads the result back for each afterimage pass.

Parity of the passes

tests/test_gpu.mojo runs each pass on both backends after a render pass and compares the images. The images agree within one level per channel. The film, glitch and halftone passes read a sine hash, and they agree within two levels. A device sin can differ from the host's in the last place, and the hash magnifies the difference. Each test also checks round_trips, so a pass with a kernel cannot fall back to the host without a failure.

Device memory on Metal

The rasterizer and its converted device helpers in render/gpu.mojo use DevicePointer to preserve the global address space across calls and struct fields. A DevicePointer is a mutable pointer in that address space. Other kernel signatures still use MutPointer; emitted call and callee types must agree at every boundary.

Metal has no generic address space. When a generic device pointer crosses a function call or is held in a struct, Mojo can drop its address space. The call then does not match the function it calls. Apple's air-lld linker crashes on that call, and Mojo reports only "Metal Compiler failed to compile metallib". See modular/modular#7238.

Follow these rules in the converted rasterizer and device-helper code:

  • Declare the pointer parameters of a helper as DevicePointer[T].
  • Declare the pointer fields of a device struct as DevicePointer[T, Self.origin].
  • Pass a buffer to a kernel that takes a DevicePointer with _device(buffer).
  • Cast to a generic pointer only at a CPU-shared helper that is @always_inline, such as output_from. Check that emitted loads retain device address spaces.
  • Read a SIMD value from device memory one lane at a time. A SIMD built from several loads in one constructor call reads zeros on Metal. See modular/modular#7158.

make check-gpu-air requires one nonempty AIR module for each discovered, plainly named kernel launch. Each generated record prints function_name and AIR from the same compiler result. !air.kernel metadata must select a complete, metal.kernel-marked definition with that exact symbol. Missing, duplicate or reused entry names fail the check.

Every enqueue_function reference in the three GPU modules, render/gpu.mojo, render/gpu_vxgi.mojo and extensions/water/gpu.mojo, must name a local, non-parameterized top-level kernel. Imported kernels, specialized launches and other unsupported syntax fail before emission. Comments and strings do not count as launches.

Source-name prefixes can be ambiguous because the pinned emitter truncates names and includes specialization text. The check uses the exact compiler-returned symbol. Other metadata forms fail closed.

The checker compares direct-call argument and return types with definitions or declarations, including quoted symbols. It supports scalar, opaque-pointer and literal aggregate types. Signatures and calls must each occupy one line. Missing signatures fail the check. Indirect calls, invoke, callbr, varargs and named types also fail the check.

This check covers supported call types. It does not validate every LLVM rule or execute GPU code. It needs MAX, but it needs no GPU and no Apple toolchain. CI runs it.

The first compile of a changed rasterize_kernel takes about 15 minutes on an Apple M4 Max. Metal caches the result, and a later compile of the same kernel takes seconds.

Kernel size on Metal

Keep each expensive helper at one call site in rasterize_kernel. Mojo marks every function alwaysinline for Metal, also one marked @no_inline. So each call site copies the whole callee into the kernel.

The Metal compiler in macOS 26.7 crashes on a kernel that holds too many copies of some code. The crash is in MTLCompilerService, a SmallVector that cannot grow. Mojo reports only "Failed to create compute pipeline state", after several retries. The offline translator in the Metal Toolchain compiles the same AIR. The size of the kernel does not predict the crash, but fewer copies make it go away.

Follow these rules in the rasterizer and in the code it calls:

  • Run node programs through one run_nodes call in a loop over the outputs. The fragment outputs do this.
  • Shape a light's shadow at one call site. Each lighting function walks every light in one loop through _light_slot, and _light_through calls shape.shaped once for every kind.
  • Read a node program's texture through NodeSource.sample_either, so that a plain read and an explicit-gradient read share one footprint.
  • Use one call site in a loop for each read of _sample_filtered, _sample_level and _fetch.
  • Keep exponent-tagged expansions (math/scaled_products.mojo) out of code that runs for each texel. _principal_axes divides by a power of two instead.

The first run of a changed kernel compiles it in about four minutes on an M4 Max. Metal caches the result. Run one GPU test first, and check that it passes before you start the whole suite. A crash shows as MTLCompilerService reports in ~/Library/Logs/DiagnosticReports.

Teardown

GpuRenderer.__deinit__ and GpuComposer.__deinit__ wait for the queue, release every buffer, and then release the context. That order prevents a hang under CUDA. See The CUDA teardown hang.

Coverage

The coverage tool excludes this module. A kernel has no stderr for the probes to write to. The parity tests cover it instead.

Performance

make bench compares the two backends on one triangle. The numbers include allocation and the copy back. On an Apple M4 Max, the CPU wins at every size, from 56 times at 320 by 240 to 2 times at 3840 by 2160. A one-shot render_triangles allocates and copies every buffer the kernel binds, which costs about 17 ms before a pixel is drawn. See Benchmarks.

Home

Tutorials

How-to

Reference

Explanation

Clone this wiki locally