Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

postprocess Module

Public Luau surface over the __postprocess Internal FFI namespace — fullscreen post-process effect registration and control.

Purpose

Wrap the raw __postprocess.* FFI namespace in a typed Luau table that gets auto-injected as _G.postprocess via the prelude. add, setProperty and setTexture report what the chain did with the call — the engine applies the mutation on the caller's own stack, and the boolean is its answer; the compiler's message and the reason for a refusal are in the engine log. A call made from inside queue() or batch() is applied later and answers for the queued request.

add is the one call that puts a pass into the frame. A post-process .shader asset defines an effect; registering it here is what makes it render, and remove takes it back out while leaving the asset in place. Editing a registered shader's body recompiles its pass in place — same enabled state, priority, layer, tuned property values and bound textures, new pixels.

The display pass

"__display__" is always in the chain, between the scene layer and the all layer — status() lists it with layer = "display". It carries two properties: curve (0 linear clamp, 1 Reinhard, 2 ACES, 3 Khronos PBR Neutral — the default) and exposure (default 1, a multiplier on the scene's light before the curve runs). Read it with postprocess.describe("__display__") and write it with postprocess.setProperty("__display__", "curve", 2); remove("__display__") and setEnabled("__display__", false) are both refused, so a scene's picture always reaches the screen through some exposure and some curve. A PostProcess component's display field is the load-time authoring of the same two properties.

Usage

local vignette = asset.resolve("@builtin::shaders.post.vignette", "shader")

-- No layer: the vignette reads light, so the chain runs it on `scene`, over
-- the rendered picture before the UI reaches it.
postprocess.add("vignette", vignette, { priority = 200 })
postprocess.setProperty("vignette", "intensity", 0.6)

-- Later
postprocess.setEnabled("vignette", false)
postprocess.remove("vignette")

-- Inspect
for _, name in ipairs(postprocess.list()) do print(name) end

-- Read one effect in full: its chain state, the schema it declared,
-- and the value each of those properties holds right now.
local e = postprocess.describe("vignette")
print(e.enabled, e.priority, e.layer, e.values.intensity)
for _, p in ipairs(e.properties) do print(p.name, p.type, p.min, p.max) end

WGSL text is accepted in place of an asset reference, and then opts.properties declares the whole property schema; this form is deprecated and will be removed, and logs a WARN naming the effect each time it is used — author the effect as a .shader asset and pass that reference instead:

postprocess.add("fade", [[
// @domain: post-process
fn fragment(in: PostInput) -> vec4<f32> {
    let c = zero_sample_scene(in.uv);
    return mix(c, vec4<f32>(0.0, 0.0, 0.0, c.a), material.amount);
}
]], { properties = { { name = "amount", type = "float", default = 0.0 } } })

add's options

opts is optional; every field of it is:

FieldDefaultWhat it does
priority100Chain order — lower runs first.
enabledtruefalse registers the effect dormant, for a later setEnabled.
layerthe layer the effect's space belongs to — light → "scene", display → "all"Which composited image the effect grades. "scene" runs it before the UI is drawn, over the scene's own light in a float target, so it grades the rendered picture and leaves every widget on screen as authored; "all" runs it after the UI has landed, over the picture the display transform fitted to the screen, so the interface is graded along with the picture. A threshold, a blur or a grade reads the scene's highlights only on "scene" — on "all" nothing stands above 1.0. Naming "all" is what an effect that must grade the interface says, and on a world several authors share it grades everyone else's screens too.
spacethe asset's own space ("light" where the asset declares none)"light" for a threshold, blur or grade that works on the scene's linear radiance; "display" for a lookup table, dither ladder or authored overlay that works on the __display__ pass's code values. Overrides what the shader asset declares for this one registration; zero_sample_scene (and its alias zero_scene_light) answers in whichever space this names, and the chain converts at both ends where the target it runs against holds the other space.
propertiesthe asset's own schema{ { name, type, default?, min?, max?, textureDefault? } }. With a .shader asset this layers over the schema the asset declares — an entry naming a property the shader declares replaces it, one naming a new property adds it. With WGSL text it is the whole schema.
passesthe asset's own passes (empty where the asset declares none){ { name, entry, scale?, inputs? } }. Each pass names an entry in the shader, the fraction of the view it draws at (absent on the last pass, which draws at the view's size), and what it reads ("scene" — the chain's input for this effect — or the name of an earlier pass). With a .shader asset this defaults to what that asset's getPasses() declares; with WGSL text it is the whole declaration; a single-pass effect, the common case, leaves this empty.
shader—The .shader asset key the source came from. add fills this in itself when it is handed an asset reference, so editing that shader recompiles the effect in place.

textureDefault is what a type = "texture" property samples while nothing is bound to it: "white" (1,1,1,1 — the default), "black" (0,0,0,1), "normal" (0.5,0.5,1,1) or "transparent" (0,0,0,0). An effect that lays its texture over the scene wants "transparent", so the frame is untouched until setTexture binds a texture that exists.

Reading the chain back

status() is the survey — every registered effect in the order the chain runs them, with the state that decides whether each one reaches the frame. describe(name) is the deep read of one of them: the same record, plus properties (the schema the effect declared) and values (what each of those properties currently holds — the value the last setProperty wrote, or the schema default where nothing has written one). A scalar comes back as a number and a wider value as the array, which is what setProperty takes, so a property read here is written straight back.

setProperty answers whether the uniform took the value; describe answers what the effect holds now. That is the pair a pass driving its properties every frame needs, and it is what tells a mistyped property name from an effect that is not grading. values is the engine's own record of the effect — every write the chain accepted into its uniform, layered over the schema's defaults, the same record /runtime/fx/<name>/meta.json is serialized from. A write the chain refused is not in it.

Before an effect is registered its schema lives on the .shader asset it will render — asset.resolve("@builtin::shaders.post.bloom", "shader"):getProperties() names what that shader declares, which is the same set describe reports once the effect exists.

Exports

  • postprocess.add(name, shader, opts?) -> boolean, string? — false and the compiler's message when the chain refused the registration
  • postprocess.remove(name) -> boolean
  • postprocess.setEnabled(name, enabled) -> boolean
  • postprocess.setProperty(name, prop, value) -> boolean
  • postprocess.setTexture(name, prop, path) -> boolean
  • postprocess.setSampler(name, opts) -> boolean
  • postprocess.list() -> { string }
  • postprocess.status() -> { PostprocessStatus }
  • postprocess.describe(name) -> PostprocessDescription?

PostprocessStatus is { name, enabled, priority, layer, space, passes, error?, shader?, pending, textures, pendingTextures }. PostprocessDescription is that record with properties and values on it. passes names the passes the effect draws, in order — empty for a single-pass effect.