---
title: "Custom pipelines — gfx"
---

> Documentation Index
> Fetch the complete documentation index at: https://aesthetic-docs.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom pipelines — gfx

Compile your own **GLSL shaders** and draw world-space meshes through them. Below the
[3D renderer](/world-render): where `render.box` / `render.begin` give fixed shading,
a `gfx` pipeline is your own vertex + fragment shader, optionally with a texture.

## Handles

| Handle | Built by | Role |
|--------|----------|------|
| Pipeline | `gfx.pipeline{...}` | Compiled vertex+fragment shader, vertex format, blend, depth |
| Texture | `gfx.texture(path)` | PNG uploaded to the GPU |
| Layer | `gfx.layer(pipeline, tex?)` | Pipeline bound to an optional texture, ready to draw |

Build them **once** at script top level (client thread — top level and render callbacks
both qualify). Draw each frame with `render.begin_layer(layer)` inside
[`render_3d`](/world-render).

```lua
local pipeline = gfx.pipeline{
name = "solid",
vertex = [[
    #version 330
    #moj_import <minecraft:dynamictransforms.glsl>
    #moj_import <minecraft:projection.glsl>
    in vec3 Position;
    in vec4 Color;
    out vec4 vertexColor;
    void main() {
        gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
        vertexColor = Color;
    }
]],
fragment = [[
    #version 330
    in vec4 vertexColor;
    out vec4 fragColor;
    void main() { fragColor = vertexColor; }
]],
}
local layer = gfx.layer(pipeline)

module:event("render_3d", function(render)
local pos = player:position()
render.begin_layer(layer)
render.vertex(pos + vec3(0, 2, 0), 0xFF4FF2A6)
render.vertex(pos + vec3(1, 2, 0), 0xFF4FF2A6)
render.vertex(pos + vec3(1, 3, 0), 0x004FF2A6)
render.vertex(pos + vec3(0, 3, 0), 0x004FF2A6)
render.finish()
end)
```

## `gfx.pipeline`

Compiles from inline GLSL. A compile error raises a Lua error; the GLSL log lands in
`latest.log`.

| Field | Default | Meaning |
|-------|---------|---------|
| `name` | required | Label for errors and GPU debug |
| `vertex` | required | GLSL vertex source |
| `fragment` | required | GLSL fragment source |
| `format` | `"position_color"` | Vertex layout — decides shader inputs and what `vertex`/`uv` write |
| `mode` | `"quads"` | `quads`, `triangles`, `triangle_strip`, `triangle_fan`, `lines`, `line_strip`, `points` |
| `blend` | `"translucent"` | `none`, `translucent`, `premultiplied`, `additive`, `lightning`, `overlay`, `glint`, `invert` |
| `depth_test` | `"lequal"` | `lequal`, `less`, `equal`, `greater`, `none` — `"none"` draws through walls |
| `depth_write` | `false` | Whether fragments write depth |
| `cull` | `false` | Back-face culling |

No per-mesh `through_walls` flag — depth is baked into the pipeline. Use
`depth_test = "none"` for x-ray shapes.

Pipelines recompile after resource reload (F3+T), so a live script keeps working.

## Shader environment

Vertex inputs by `format`:

| `format` | Inputs | Texture |
|----------|--------|---------|
| `position` | `in vec3 Position;` | — |
| `position_color` | `in vec3 Position; in vec4 Color;` | — |
| `position_tex` | `in vec3 Position; in vec2 UV0;` | `Sampler0` |
| `position_tex_color` | `in vec3 Position; in vec2 UV0; in vec4 Color;` | `Sampler0` |

Standard clip transform: `gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);`.
Positions are camera-relative — feed `render.vertex` plain world coordinates.

Vanilla uniform blocks via `#moj_import`:

| Import | Provides |
|--------|----------|
| `<minecraft:dynamictransforms.glsl>` | `ModelViewMat`, `ColorModulator`, `ModelOffset`, `TextureMat` |
| `<minecraft:projection.glsl>` | `ProjMat`, `projection_from_position` |
| `<minecraft:globals.glsl>` | `GameTime`, `ScreenSize`, `CameraBlockPos`, `CameraOffset`, … |

Textured pipelines declare `uniform sampler2D Sampler0;` and sample with interpolated
`UV0`. `GameTime` (fraction of the day, wraps every 24000 ticks) is the usual animation
handle. No custom uniforms — drive everything from `GameTime`, transforms and vertex data.

## `gfx.texture`

`gfx.texture(path)` loads a PNG and uploads it. Path is relative to the scripts directory
(absolute paths work). Returns the handle plus pixel size. Released when the script unloads.

```lua
local tex, w, h = gfx.texture("textures/logo.png")
```

## `gfx.layer`

`gfx.layer(pipeline, texture?)` binds a pipeline to a texture. `position_tex*` formats
**require** a texture (a `gfx.texture` handle or a vanilla id string). Plain
`position` / `position_color` must be called with no texture. Layers are memoized per
`(pipeline, texture)`, so calling every frame is fine.

```lua
local textured = gfx.pipeline{ name = "billboard", format = "position_tex", vertex = ..., fragment = ... }
local layer    = gfx.layer(textured, tex)
local creeper  = gfx.layer(textured, "minecraft:textures/entity/creeper/creeper.png")
```

## Drawing

Inside `render_3d`, `render.begin_layer(layer)` opens a mesh whose mode and format come
from the pipeline:

- `render.vertex(pos[, color])` — world-space [vec3](/vec3). Color only when the format
  carries one; sticky until changed.
- `render.uv(u, v)` — texture coords for subsequent vertices (`position_tex*` only); sticky.
- `render.finish()` — flushes the mesh.

Vertex count must match the mode: quads ×4, triangles ×3, strips/fans ≥3, lines ×2.
A mismatch raises an error.

Textured, `GameTime`-scrolling billboard:

```lua
local pipeline = gfx.pipeline{
name = "scroll",
format = "position_tex",
blend = "additive",
depth_test = "none",
vertex = [[
    #version 330
    #moj_import <minecraft:dynamictransforms.glsl>
    #moj_import <minecraft:projection.glsl>
    #moj_import <minecraft:globals.glsl>
    in vec3 Position;
    in vec2 UV0;
    out vec2 uv;
    void main() {
        gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
        uv = UV0 + vec2(GameTime * 20.0, 0.0);
    }
]],
fragment = [[
    #version 330
    uniform sampler2D Sampler0;
    in vec2 uv;
    out vec4 fragColor;
    void main() { fragColor = texture(Sampler0, uv); }
]],
}
local layer = gfx.layer(pipeline, gfx.texture("textures/aura.png"))

module:event("render_3d", function(render)
for _, p in ipairs(world:players()) do
    if not p:is_self() then
        render.push()
        render.origin(p:position() + vec3(0, p:height() / 2, 0))
        render.rotate_camera()
        render.begin_layer(layer)
        render.uv(0, 0); render.vertex(vec3(-0.6, 0.6, 0))
        render.uv(0, 1); render.vertex(vec3(-0.6, -0.6, 0))
        render.uv(1, 1); render.vertex(vec3(0.6, -0.6, 0))
        render.uv(1, 0); render.vertex(vec3(0.6, 0.6, 0))
        render.finish()
        render.pop()
    end
end
end)
```

`push` / `origin` / `rotate_camera` are the same
[transform helpers](/world-render#transforms) as built-in 3D drawing.

Source: https://aesthetic-docs.pages.dev/gfx/index.md
