CauldronGPU

3D scenes and 2D sprite layers drawn by the graphics card, described from Elixir. Your code keeps the game world and sends the renderer what changed each tick; a native render thread draws at the display's rate from its own copy of the scene, moving things smoothly between your ticks.

The same scene can go to a window, to an offscreen picture you read back (and show in a terminal), to a renderer in its own OS process, or to browsers (cauldron_3d_net).

Installation

def deps do
  [{:cauldron_gpu, "~> 0.3"}]
end

The package always compiles. The native renderer is built when what it needs is on the machine; without it, CauldronGPU.backends/0 answers [], CauldronGPU.open/1 answers {:error, reason}, and games draw in software. To set the renderer up, run:

mix cauldron_gpu.setup

It looks for what the build needs in the platform's usual places, offers to install what is missing (showing what it will run), builds the renderer and opens the graphics card to check it. Declining leaves software drawing; running it again starts over. --check only reports, --yes accepts everything, and --software builds no renderer.

Platform What the build needs What the setup installs
macOS Apple's command-line tools, SDL3 and pkg-config; for the Vulkan backend, Vulkan's loader and headers and MoltenVK the command-line tools through Apple's installer, the rest through Homebrew (and Homebrew itself)
Linux a C++20 compiler, make, pkg-config, SDL3, Vulkan's headers, and a Vulkan driver to run it packages through apt, dnf, pacman or zypper with sudo; Vulkan's headers by download; SDL3 built from source where the distribution has none
Windows MSVC's C++ Build Tools, SDL3's development files, Vulkan's headers, and a Vulkan driver MSVC through winget; SDL3 and Vulkan's headers by download, with no administrator rights

The renderer loads Vulkan from the graphics driver when it runs, so only Vulkan's headers are needed to build it, not LunarG's SDK. Downloads go into :filename.basedir(:user_cache, "cauldron_gpu"), and the paths found are kept in build.config under :filename.basedir(:user_config, "cauldron_gpu"), which later builds read (CauldronGPU.Setup). Environment variables set by hand take precedence: SDL3_DIR, VULKAN_INCLUDE (or VULKAN_SDK), PKG_CONFIG and PKG_CONFIG_PATH.

Shaders come compiled in priv/shaders; make shaders rebuilds them after a change in shaders/, which needs the Slang compiler (make slang) and, for Metal, Xcode.

On macOS the Metal backend is always built, and Vulkan beside it when Vulkan is found. Metal is used first on a Mac; Vulkan there runs the same path as Linux and Windows. Set CAULDRON_GPU_VULKAN=0 to leave Vulkan out.

A first scene

alias CauldronGPU.{Camera, Instance, Light, Material, Mesh, Transform}

{:ok, gpu} = CauldronGPU.open(title: "Hello", width: 1280, height: 720)

cube = CauldronGPU.mesh(gpu, Mesh.cube(1.0))
floor = CauldronGPU.mesh(gpu, Mesh.plane(20, 4))
red = CauldronGPU.material(gpu, %Material{base_color: {0.8, 0.1, 0.1, 1.0}, roughness: 0.4})
grey = CauldronGPU.material(gpu, %Material{base_color: {0.5, 0.5, 0.5, 1.0}})

CauldronGPU.submit(gpu, [
  {:instance, 1, %Instance{mesh: floor, materials: [grey]}},
  {:instance, 2, %Instance{mesh: cube, materials: [red], transform: Transform.new(position: {0.0, 0.5, 0.0})}},
  {:light, 1, %Light{kind: :directional, direction: {-0.4, -1.0, -0.3}, intensity: 3.0, shadows: true}},
  %Camera{position: {0.0, 1.5, 4.0}}
])

Then, once a tick (say 30 times a second), send what moved:

CauldronGPU.submit(gpu, [
  {:move, 2, Transform.new(position: {x, 0.5, 0.0})},
  %Camera{position: eye, rotation: CauldronGPU.Quat.yaw_pitch(yaw, pitch)}
])

Window events arrive at the process that opened it (or the :events pid) as {:cauldron_gpu, window_id, event}: keys ({:key, :down | :up, key, repeat?}), mouse motion, buttons and wheel, resizes, focus and :close_requested. CauldronGPU.relative_mouse/2 hides the pointer for mouse look.

How a scene works

Where the frames go

Use How
A window CauldronGPU.open(title: ..., width: ..., height: ...)
Pictures read back CauldronGPU.open(window: false, width: w, height: h) then CauldronGPU.capture/3 for RGBA bytes (CauldronGPU.PNG.encode/1 saves one) or, with shared_memory: name, into a POSIX shared-memory object a terminal can read
A terminal Draw offscreen and send the pictures with the kitty graphics protocol. See examples/terminal.exs, or a cauldron_2d game's gpu picture option below
Its own OS process CauldronGPU.Host takes the same calls. A renderer crash leaves the VM running. CauldronGPU.Host.restart/1 brings the renderer back with the scene it had
Browsers Cauldron3D.Net.Scene in the cauldron_3d_net package

Code that uploads should take any of these through the CauldronGPU.Target protocol (submit/2, mesh/2, texture/2, material/2, release/2).

Looks

Game formats

With cauldron_3d and cauldron_3d_formats (and cauldron_2d for sector maps) in your deps, these converters upload a whole asset and give the changes that place it:

2D layers

A tick can carry {:layers, [layer]}: 2D layers drawn every frame, in order, until the next :layers change. A CauldronGPU.SpriteLayer draws CauldronGPU.Sprites, each a rectangle of a texture placed, scaled, turned, tinted and blended (:over, :add or :subtract). A CauldronGPU.ColumnLayer redraws a texture column by column through a table of positions, stretches and shades, for warps such as a curved view.

A layer draws into the frame or into a canvas. A canvas (CauldronGPU.canvas/2, a CauldronGPU.Canvas) is a texture on the card, so a later layer can draw it as a sprite, warp it, or a material can use it.

alias CauldronGPU.{Canvas, Sprite, SpriteLayer, Texture}

{:ok, gpu} = CauldronGPU.open(title: "Sprites", width: 960, height: 600)
ship = CauldronGPU.texture(gpu, %Texture{width: 16, height: 16, pixels: pixels,
  format: :linear, filter: :nearest, wrap: :clamp, mipmaps: false})
screen = CauldronGPU.canvas(gpu, %Canvas{width: 320, height: 200})

CauldronGPU.submit(gpu, [
  {:layers,
   [
     %SpriteLayer{target: screen, clear: {0.0, 0.0, 0.1, 1.0},
       sprites: [%Sprite{texture: ship, position: {160, 100}, origin: {8, 8}, angle: 30}]},
     %SpriteLayer{clear: {0.0, 0.0, 0.0, 1.0}, view: {0, 0, 320, 200},
       sprites: [%Sprite{texture: screen, position: {0, 0}}]}
   ]}
])

Colours in layers are display values, blended as stored, the way Cauldron2D.Blend blends bytes; upload 2D pictures with format: :linear so they reach the card unchanged. A pixel is drawn when its centre lies inside the sprite, and takes the texel found by mapping that centre back into the picture, so a sprite lands on the same pixels as Cauldron2D.DrawList draws it. The frame's layers go over the 3D scene. A renderer that has never been sent a camera draws its layers alone and skips the 3D passes.

Drawing a cauldron_2d game on the graphics card

A Cauldron2D.Client.Game whose scene has a :picture (its own software renderer) can add a :gpu function describing the same picture for the card. The client in cauldron_2d_drafter then draws it on the graphics card when the player asks for it, and falls back to the software picture when the card is not there.

def scene(view, look) do
  %{
    focus: {0.0, 0.0},
    bounds: :unbounded,
    cell: fn _ -> nil end,
    movers: [],
    picture: fn size, aspect -> MyGame.Render.picture(view, size, aspect) end,
    gpu: fn target, cache, size, aspect -> MyGame.Gpu.draw(view, target, cache, size, aspect) end
  }
end

MyGame.Gpu.draw/5 answers {changes, cache}:

The player picks where it is drawn with the client's picture options (Cauldron2D.Drafter.Client.Picture): gpu draws it offscreen and shows it in the terminal pane, window opens a window of its own whose keys drive the game. A game's mix task usually offers them as --gpu and --window. With no :gpu in the scene, no renderer in the build, or a renderer that fails to open, the game's own picture is drawn and the player sees a short note saying why. The renderer runs as a CauldronGPU.Host, so a renderer crash does not take the game down.

elixir_quake, elixir_doom and elixir_half_life are complete examples of this in 3D, and nidhox (Nidhox.Gpu) in 2D.

Examples

Run from this directory:

Tests and status

STATE.md records what has been built, how it was checked, and what is open (DLSS and FSR, Vulkan ray tracing on real hardware, first runs on Linux and Windows).