Log inGet started

postprocess

Updated 5 September 2026

The postprocess namespace — 28 functions.

globals/postprocess/add

postprocess.add(name: string, shader: string | AssetRef, opts: PostprocessOpts?) -> boolean

Register a fullscreen post-process effect. This call is what puts a pass into the frame — a post-process .shader asset defines an effect, and renders only once registered here. The chain applies the registration on the caller's own stack and the returned boolean is its answer, so a setProperty or setTexture naming the effect in the same call finds it. shader is a .shader asset reference whose shader.wgsl provides fn fragment(in: PostInput) -> vec4<f32> and whose properties.yaml declares the effect's properties; the engine generates the group(0) framework + schema-driven group(1) from that schema. Editing that shader afterwards recompiles this effect in place, keeping its enabled state, priority, layer and tuned property values. WGSL text is also accepted, and then opts.properties is the whole schema. Effects run in priority order (lower first, default 100). A registered effect runs over the live viewport's frame AND over every offscreen one — a capture from a world-space station, one orbiting an entity, one of a named camera, a render-to-texture camera. In each of those the effect's engine.view_proj / engine.prev_view_proj / engine.inv_view_proj are the camera THAT render was drawn from and engine.resolution is that target's own size, so a pass reconstructing world space from zero_scene_depth(uv) reconstructs against the station and lens the capture asked for. An offscreen capture is therefore an oracle for an authored grade: it photographs a chosen station without taking the on-screen camera from whoever else is driving the scene, and a capture's postProcessing = false is the one control that takes the chain off the frame it returns. An offscreen render keeps no view history of its own, so engine.prev_view_proj there holds that same matrix rather than the frame before it, and a pass taking camera motion from the two reads none.

Parameters

  • name string — Unique effect name.
  • shader string | AssetRef — A resolved shader asset reference, or author WGSL (fn fragment(in: PostInput) only).
  • opts PostprocessOpts (optional){ priority = 100, enabled = true, layer = "all"|"scene", properties = {{name, type, default?, min?, max?, textureDefault?}} } — with a shader asset, properties layers over the asset's own schema. layer picks which composited image the effect grades: "scene" runs it before the UI is drawn, so it grades the rendered picture and leaves every widget on screen as authored, and "all" (the default) runs it after the UI has landed, so the interface is graded along with the picture — an effect that belongs to the world's look wants "scene", since a screen another author drew is otherwise graded by it too. 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.

Returns boolean — True when the chain registered the effect; false when it refused it. A shader that does not compile draws nothing at any property value, so it is not registered and postprocess.list() never names it — the compiler's message is in the engine log. A call made from inside queue() or batch(), where the engine has not run the registration by the time the call returns, answers true for the queued request.

postprocess.add("vignette", asset.resolve("@builtin::shaders.post.vignette", "shader"))
postprocess.add("vignette", VIGNETTE_WGSL, { properties = {{ name = "intensity", type = "float", default = 0.5 }} })

globals/postprocess/describe

postprocess.describe(name: string) -> PostprocessDescription?

One effect by name, read in full: the chain state postprocess.status() lists for it, and on top of that properties — the schema the effect declared, each entry { name, type, default?, min?, max?, textureDefault? } in the shape add takes — and values, what each of those properties currently holds. A property's value is the one the last setProperty wrote, or the schema's own default where nothing has written one, and it comes back as a number for a scalar and as the array for a wider value, which is what setProperty takes, so a property read here is written straight back.

This is the read-back for a property write. setProperty answers whether the uniform took the value; this answers what the effect holds now, which is the reading a pass that writes its properties every frame needs and the one that tells a mistyped property name from an effect that is not grading. The schema and the values are the engine's own record of the effect — the schema it was registered with and every write the chain accepted into its uniform, the same record /runtime/fx/<name>/meta.json is serialized from. A write the chain refused is not in it, and neither is one made against a property the schema does not declare.

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.

Parameters

  • name string — Effect name.

Returns PostprocessDescription? — The effect's state, schema and live values, or nil when nothing is registered under the name. An effect the renderer registers itself declares no properties of its own, and its properties and values are empty.

local e = postprocess.describe("vignette")
print(if e then e.values.intensity else "not registered")
for _, p in ipairs(postprocess.describe("bloom").properties) do
print(p.name, p.type, p.min, p.max)
end

globals/postprocess/list

postprocess.list() -> { string }

List all registered post-process effect names in renderer priority order (lower priority runs first).

Returns { string } — Array of effect names.

for _, n in ipairs(postprocess.list()) do print(n) end

globals/postprocess/remove

postprocess.remove(name: string) -> boolean

Queue removal of a post-process effect. Takes effect on the next frame. Removing a name that isn't registered is a silent no-op.

Parameters

  • name string — Effect name to remove.

Returns boolean — True — the mutation was queued.

postprocess.remove("vignette")

globals/postprocess/setEnabled

postprocess.setEnabled(name: string, enabled: boolean) -> boolean

Queue an enable/disable toggle on a registered post-process effect. Targeting an unknown name is a silent no-op.

Parameters

  • name string — Effect name.
  • enabled boolean — True to enable, false to disable.

Returns boolean — True — the mutation was queued.

postprocess.setEnabled("bloom", false)

globals/postprocess/setProperty

postprocess.setProperty(name: string, prop: string, value: (number | { number })) -> boolean

Set a named material property on a registered post-process effect. The property must be declared in the effect's properties schema; read in WGSL as material.<prop>. value is a number or a number array (vec/color).

Parameters

  • name string — Effect name.
  • prop string — Declared property name.
  • value (number | { number }) — Number or array of numbers.

Returns boolean — True when the effect's uniform took the value; false when it did not — an effect that is not registered, or one that declares no property by that name, is named in a WARN in the engine log. A call made from inside queue() or batch(), where the engine has not run the write by the time the call returns, answers true for the queued request.

postprocess.setProperty("vignette", "intensity", 0.6)

globals/postprocess/setSampler

postprocess.setSampler(name: string, opts: { [string]: any }) -> boolean

Configure the per-effect user sampler shared by the effect's declared texture properties. opts.filter = "linear" (default) or "nearest". opts.wrap (alias .address) = "clamp" (default), "repeat", or "mirror" — applied to all axes.

Parameters

  • name string — Effect name.
  • opts { [string]: any }{ filter = "linear"|"nearest", wrap = "clamp"|"repeat"|"mirror" }.

Returns boolean — True — the mutation was queued.

postprocess.setSampler("blur", { filter = "linear", wrap = "clamp" })

globals/postprocess/setTexture

postprocess.setTexture(name: string, prop: string, path: string) -> boolean

Bind a texture to one of an effect's declared texture properties. Declare it in properties ({ name = "noise", type = "texture" }) and sample in WGSL as textureSample(noise, noise_sampler, in.uv). path is any TextureCache-resolvable spec (@builtin::textures.foo, color:1,0,0, default:white, a render-target name, ...). A path whose texture has not reached the GPU yet — one this same script created — is held and bound as soon as it does; postprocess.status() reports it under pendingTextures until then.

Parameters

  • name string — Effect name.
  • prop string — Declared texture-property name.
  • path string — Texture path / spec.

Returns boolean — True when the slot took the binding, including one held until its texture reaches the GPU; false when it did not — an effect that is not registered, or one that declares no texture property by that name, is named in a WARN in the engine log. A call made from inside queue() or batch(), where the engine has not run the binding by the time the call returns, answers true for the queued request.

postprocess.setTexture("__vsky_composite", "cloud", "cloud_target")

globals/postprocess/status

postprocess.status() -> { PostprocessStatus }

Every registered effect in chain order with the state that decides whether it reaches the frame — enabled flag, priority, layer, the shader's compile error when it has one, the .shader asset it renders when it was registered from one, the texture each declared slot is bound to (textures) and the bindings still waiting for their texture (pendingTextures). This is what the renderer draws with, so a survey of the chain answers "is this one affecting the picture right now?" without capturing a frame and reading pixels.

An effect this script has just registered is listed with pending = true until the renderer publishes it, since a registration is queued for the next frame.

Returns { PostprocessStatus } — Array of per-effect state, in the order the chain runs them.

for _, e in ipairs(postprocess.status()) do
print(e.name, e.enabled, e.layer, e.error)
end

modules/postprocess/README

require("@builtin/modules/api/engine/postprocess") -- postprocess (also available as global 'postprocess')

Fullscreen post-process effects — register / remove / toggle / named-property updates / list the chain / read one effect's declared property schema and the value each property holds. Public Luau surface over the __postprocess Internal FFI namespace.

Usage: local postprocess = require("@builtin/modules/api/engine/postprocess") Also available as global: postprocess

modules/postprocess/add

add(name: string, shader: string | AssetRef, opts: PostprocessOpts?): boolean

Register a fullscreen post-process effect. This call is what puts a pass into the frame — a post-process .shader asset defines an effect, and renders only once registered here. The chain applies the registration on the caller's own stack and the returned boolean is its answer, so a setProperty or setTexture naming the effect in the same call finds it. shader is a .shader asset reference whose shader.wgsl provides fn fragment(in: PostInput) -> vec4<f32> and whose properties.yaml declares the effect's properties; the engine generates the group(0) framework + schema-driven group(1) from that schema. Editing that shader afterwards recompiles this effect in place, keeping its enabled state, priority, layer and tuned property values. WGSL text is also accepted, and then opts.properties is the whole schema. Effects run in priority order (lower first, default 100). A registered effect runs over the live viewport's frame AND over every offscreen one — a capture from a world-space station, one orbiting an entity, one of a named camera, a render-to-texture camera. In each of those the effect's engine.view_proj / engine.prev_view_proj / engine.inv_view_proj are the camera THAT render was drawn from and engine.resolution is that target's own size, so a pass reconstructing world space from zero_scene_depth(uv) reconstructs against the station and lens the capture asked for. An offscreen capture is therefore an oracle for an authored grade: it photographs a chosen station without taking the on-screen camera from whoever else is driving the scene, and a capture's postProcessing = false is the one control that takes the chain off the frame it returns. An offscreen render keeps no view history of its own, so engine.prev_view_proj there holds that same matrix rather than the frame before it, and a pass taking camera motion from the two reads none.

Parameters

  • name string — Unique effect name.
  • shader string | AssetRef — A resolved shader asset reference, or author WGSL (fn fragment(in: PostInput) only).
  • opts PostprocessOpts? (optional){ priority = 100, enabled = true, layer = "all"|"scene", properties = {{name, type, default?, min?, max?, textureDefault?}} } — with a shader asset, properties layers over the asset's own schema. layer picks which composited image the effect grades: "scene" runs it before the UI is drawn, so it grades the rendered picture and leaves every widget on screen as authored, and "all" (the default) runs it after the UI has landed, so the interface is graded along with the picture — an effect that belongs to the world's look wants "scene", since a screen another author drew is otherwise graded by it too. 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.
postprocess.add("vignette", asset.resolve("@builtin::shaders.post.vignette", "shader"))
postprocess.add("vignette", VIGNETTE_WGSL, { properties = {{ name = "intensity", type = "float", default = 0.5 }} })

modules/postprocess/describe

describe(name: string): PostprocessDescription?

One effect by name, read in full: the chain state postprocess.status() lists for it, and on top of that properties — the schema the effect declared, each entry { name, type, default?, min?, max?, textureDefault? } in the shape add takes — and values, what each of those properties currently holds. A property's value is the one the last setProperty wrote, or the schema's own default where nothing has written one, and it comes back as a number for a scalar and as the array for a wider value, which is what setProperty takes, so a property read here is written straight back.

This is the read-back for a property write. setProperty answers whether the uniform took the value; this answers what the effect holds now, which is the reading a pass that writes its properties every frame needs and the one that tells a mistyped property name from an effect that is not grading. The schema and the values are the engine's own record of the effect — the schema it was registered with and every write the chain accepted into its uniform, the same record /runtime/fx/<name>/meta.json is serialized from. A write the chain refused is not in it, and neither is one made against a property the schema does not declare.

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.

Parameters

  • name string — Effect name.
local e = postprocess.describe("vignette")
print(if e then e.values.intensity else "not registered")
for _, p in ipairs(postprocess.describe("bloom").properties) do
print(p.name, p.type, p.min, p.max)
end

modules/postprocess/list

list(): { string }

List all registered post-process effect names in renderer priority order (lower priority runs first).

for _, n in ipairs(postprocess.list()) do print(n) end

modules/postprocess/remove

remove(name: string): boolean

Queue removal of a post-process effect. Takes effect on the next frame. Removing a name that isn't registered is a silent no-op.

Parameters

  • name string — Effect name to remove.
postprocess.remove("vignette")

modules/postprocess/setEnabled

setEnabled(name: string, enabled: boolean): boolean

Queue an enable/disable toggle on a registered post-process effect. Targeting an unknown name is a silent no-op.

Parameters

  • name string — Effect name.
  • enabled boolean — True to enable, false to disable.
postprocess.setEnabled("bloom", false)

modules/postprocess/setProperty

setProperty(name: string, prop: string, value: (number | { number })): boolean

Set a named material property on a registered post-process effect. The property must be declared in the effect's properties schema; read in WGSL as material.<prop>. value is a number or a number array (vec/color).

Parameters

  • name string — Effect name.
  • prop string — Declared property name.
  • value (number | { number }) — Number or array of numbers.
postprocess.setProperty("vignette", "intensity", 0.6)

modules/postprocess/setSampler

setSampler(name: string, opts: { [string]: any }): boolean

Configure the per-effect user sampler shared by the effect's declared texture properties. opts.filter = "linear" (default) or "nearest". opts.wrap (alias .address) = "clamp" (default), "repeat", or "mirror" — applied to all axes.

Parameters

  • name string — Effect name.
  • opts { [string]: any }{ filter = "linear"|"nearest", wrap = "clamp"|"repeat"|"mirror" }.
postprocess.setSampler("blur", { filter = "linear", wrap = "clamp" })

modules/postprocess/setTexture

setTexture(name: string, prop: string, path: string): boolean

Bind a texture to one of an effect's declared texture properties. Declare it in properties ({ name = "noise", type = "texture" }) and sample in WGSL as textureSample(noise, noise_sampler, in.uv). path is any TextureCache-resolvable spec (@builtin::textures.foo, color:1,0,0, default:white, a render-target name, ...). A path whose texture has not reached the GPU yet — one this same script created — is held and bound as soon as it does; postprocess.status() reports it under pendingTextures until then.

Parameters

  • name string — Effect name.
  • prop string — Declared texture-property name.
  • path string — Texture path / spec.
postprocess.setTexture("__vsky_composite", "cloud", "cloud_target")

modules/postprocess/status

status(): { PostprocessStatus }

Every registered effect in chain order with the state that decides whether it reaches the frame — enabled flag, priority, layer, the shader's compile error when it has one, the .shader asset it renders when it was registered from one, the texture each declared slot is bound to (textures) and the bindings still waiting for their texture (pendingTextures). This is what the renderer draws with, so a survey of the chain answers "is this one affecting the picture right now?" without capturing a frame and reading pixels.

An effect this script has just registered is listed with pending = true until the renderer publishes it, since a registration is queued for the next frame.

for _, e in ipairs(postprocess.status()) do
print(e.name, e.enabled, e.layer, e.error)
end

typed/builtin//modules/api/engine/postprocess/postprocess/add

postprocess.add(name: string, shader: string | AssetRef, opts: PostprocessOpts?) -> boolean

Register a fullscreen post-process effect. This call is what puts a pass into the frame — a post-process .shader asset defines an effect, and renders only once registered here. The chain applies the registration on the caller's own stack and the returned boolean is its answer, so a setProperty or setTexture naming the effect in the same call finds it. shader is a .shader asset reference whose shader.wgsl provides fn fragment(in: PostInput) -> vec4<f32> and whose properties.yaml declares the effect's properties; the engine generates the group(0) framework + schema-driven group(1) from that schema. Editing that shader afterwards recompiles this effect in place, keeping its enabled state, priority, layer and tuned property values. WGSL text is also accepted, and then opts.properties is the whole schema. Effects run in priority order (lower first, default 100). A registered effect runs over the live viewport's frame AND over every offscreen one — a capture from a world-space station, one orbiting an entity, one of a named camera, a render-to-texture camera. In each of those the effect's engine.view_proj / engine.prev_view_proj / engine.inv_view_proj are the camera THAT render was drawn from and engine.resolution is that target's own size, so a pass reconstructing world space from zero_scene_depth(uv) reconstructs against the station and lens the capture asked for. An offscreen capture is therefore an oracle for an authored grade: it photographs a chosen station without taking the on-screen camera from whoever else is driving the scene, and a capture's postProcessing = false is the one control that takes the chain off the frame it returns. An offscreen render keeps no view history of its own, so engine.prev_view_proj there holds that same matrix rather than the frame before it, and a pass taking camera motion from the two reads none.

Parameters

  • name string — Unique effect name.
  • shader string | AssetRef — A resolved shader asset reference, or author WGSL (fn fragment(in: PostInput) only).
  • opts PostprocessOpts (optional){ priority = 100, enabled = true, layer = "all"|"scene", properties = {{name, type, default?, min?, max?, textureDefault?}} } — with a shader asset, properties layers over the asset's own schema. layer picks which composited image the effect grades: "scene" runs it before the UI is drawn, so it grades the rendered picture and leaves every widget on screen as authored, and "all" (the default) runs it after the UI has landed, so the interface is graded along with the picture — an effect that belongs to the world's look wants "scene", since a screen another author drew is otherwise graded by it too. 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.

Returns boolean — True when the chain registered the effect; false when it refused it. A shader that does not compile draws nothing at any property value, so it is not registered and postprocess.list() never names it — the compiler's message is in the engine log. A call made from inside queue() or batch(), where the engine has not run the registration by the time the call returns, answers true for the queued request.

postprocess.add("vignette", asset.resolve("@builtin::shaders.post.vignette", "shader"))
postprocess.add("vignette", VIGNETTE_WGSL, { properties = {{ name = "intensity", type = "float", default = 0.5 }} })

typed/builtin//modules/api/engine/postprocess/postprocess/describe

postprocess.describe(name: string) -> PostprocessDescription?

One effect by name, read in full: the chain state postprocess.status() lists for it, and on top of that properties — the schema the effect declared, each entry { name, type, default?, min?, max?, textureDefault? } in the shape add takes — and values, what each of those properties currently holds. A property's value is the one the last setProperty wrote, or the schema's own default where nothing has written one, and it comes back as a number for a scalar and as the array for a wider value, which is what setProperty takes, so a property read here is written straight back.

This is the read-back for a property write. setProperty answers whether the uniform took the value; this answers what the effect holds now, which is the reading a pass that writes its properties every frame needs and the one that tells a mistyped property name from an effect that is not grading. The schema and the values are the engine's own record of the effect — the schema it was registered with and every write the chain accepted into its uniform, the same record /runtime/fx/<name>/meta.json is serialized from. A write the chain refused is not in it, and neither is one made against a property the schema does not declare.

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.

Parameters

  • name string — Effect name.

Returns PostprocessDescription? — The effect's state, schema and live values, or nil when nothing is registered under the name. An effect the renderer registers itself declares no properties of its own, and its properties and values are empty.

local e = postprocess.describe("vignette")
print(if e then e.values.intensity else "not registered")
for _, p in ipairs(postprocess.describe("bloom").properties) do
print(p.name, p.type, p.min, p.max)
end

typed/builtin//modules/api/engine/postprocess/postprocess/list

postprocess.list() -> { string }

List all registered post-process effect names in renderer priority order (lower priority runs first).

typed/builtin//modules/api/engine/postprocess/postprocess/remove

postprocess.remove(name: string) -> boolean

Queue removal of a post-process effect. Takes effect on the next frame. Removing a name that isn't registered is a silent no-op.

Parameters

  • name string — Effect name to remove.

Returns boolean — True — the mutation was queued.

postprocess.remove("vignette")

typed/builtin//modules/api/engine/postprocess/postprocess/setEnabled

postprocess.setEnabled(name: string, enabled: boolean) -> boolean

Queue an enable/disable toggle on a registered post-process effect. Targeting an unknown name is a silent no-op.

Parameters

  • name string — Effect name.
  • enabled boolean — True to enable, false to disable.

Returns boolean — True — the mutation was queued.

postprocess.setEnabled("bloom", false)

typed/builtin//modules/api/engine/postprocess/postprocess/setProperty

postprocess.setProperty(name: string, prop: string, value: (number | { number })) -> boolean

Set a named material property on a registered post-process effect. The property must be declared in the effect's properties schema; read in WGSL as material.<prop>. value is a number or a number array (vec/color).

Parameters

  • name string — Effect name.
  • prop string — Declared property name.
  • value (number | { number }) — Number or array of numbers.

Returns boolean — True when the effect's uniform took the value; false when it did not — an effect that is not registered, or one that declares no property by that name, is named in a WARN in the engine log. A call made from inside queue() or batch(), where the engine has not run the write by the time the call returns, answers true for the queued request.

postprocess.setProperty("vignette", "intensity", 0.6)

typed/builtin//modules/api/engine/postprocess/postprocess/setSampler

postprocess.setSampler(name: string, opts: { [string]: any }) -> boolean

Configure the per-effect user sampler shared by the effect's declared texture properties. opts.filter = "linear" (default) or "nearest". opts.wrap (alias .address) = "clamp" (default), "repeat", or "mirror" — applied to all axes.

Parameters

  • name string — Effect name.
  • opts { [string]: any }{ filter = "linear"|"nearest", wrap = "clamp"|"repeat"|"mirror" }.

Returns boolean — True — the mutation was queued.

postprocess.setSampler("blur", { filter = "linear", wrap = "clamp" })

typed/builtin//modules/api/engine/postprocess/postprocess/setTexture

postprocess.setTexture(name: string, prop: string, path: string) -> boolean

Bind a texture to one of an effect's declared texture properties. Declare it in properties ({ name = "noise", type = "texture" }) and sample in WGSL as textureSample(noise, noise_sampler, in.uv). path is any TextureCache-resolvable spec (@builtin::textures.foo, color:1,0,0, default:white, a render-target name, ...). A path whose texture has not reached the GPU yet — one this same script created — is held and bound as soon as it does; postprocess.status() reports it under pendingTextures until then.

Parameters

  • name string — Effect name.
  • prop string — Declared texture-property name.
  • path string — Texture path / spec.

Returns boolean — True when the slot took the binding, including one held until its texture reaches the GPU; false when it did not — an effect that is not registered, or one that declares no texture property by that name, is named in a WARN in the engine log. A call made from inside queue() or batch(), where the engine has not run the binding by the time the call returns, answers true for the queued request.

postprocess.setTexture("__vsky_composite", "cloud", "cloud_target")

typed/builtin//modules/api/engine/postprocess/postprocess/status

postprocess.status() -> { PostprocessStatus }

Every registered effect in chain order with the state that decides whether it reaches the frame — enabled flag, priority, layer, the shader's compile error when it has one, the .shader asset it renders when it was registered from one, the texture each declared slot is bound to (textures) and the bindings still waiting for their texture (pendingTextures). This is what the renderer draws with, so a survey of the chain answers "is this one affecting the picture right now?" without capturing a frame and reading pixels.

An effect this script has just registered is listed with pending = true until the renderer publishes it, since a registration is queued for the next frame.

Returns { PostprocessStatus } — Array of per-effect state, in the order the chain runs them.

for _, e in ipairs(postprocess.status()) do
print(e.name, e.enabled, e.layer, e.error)
end
  • api
  • reference