Post-processing, fog, sky and camera
Status: Implemented (API 1.13). Screen-space post passes from mod GLSL with mod-driven uniforms, uniforms and
MOD_<ID>defines for shader packs, the mutablerender.fogevent, sky colour, lightmap tint and ambient darkness overrides, camera effects (FOV, roll, view bobbing, vignette, opt-in shake), per-entity render parameters, material variants and custom fragment shaders for glTF models. Depth input for post passes and compute passes are Planned.
A horror mod in this guide: the screen darkens and turns red when the creature is near, the fog closes in, the sky goes grey, the camera tilts and breathes, and the creature’s skin gets bloody as it takes damage. All client-side drawing; vanilla rendering is untouched until a mod asks for something.
Reference: Rendering, render, camera, model, events.
1. Post passes
A post pass is a GLSL function in your mod’s assets. It gets the picture so far and returns the new colour:
// assets/horror/shaders/post/dread.glsl
vec4 cl_post(vec2 uv) {
vec4 c = cl_scene(uv);
float grey = dot(c.rgb, vec3(0.299, 0.587, 0.114));
float edge = smoothstep(0.3, 0.8, length(uv - 0.5) * 1.4);
vec3 col = mix(c.rgb, vec3(grey), 0.5 * fear);
return vec4(mix(col, vec3(0.4, 0.0, 0.0), edge * fear), 1.0);
}
local dread = cl.render.post_pass{
id = "dread", shader = "horror:shaders/post/dread.glsl",
uniforms = function(pass, frame) -- runs every frame
return { fear = current_fear }
end,
}
-- later: dread:set_enabled(false), dread:remove()
The pass runs after the world and the hand and before the HUD, so the HUD, chat and screens stay crisp. Passes run in order (lower first); every enabled pass costs one full-screen draw and a copy, and none runs while none is enabled (the frame is exactly vanilla then).
What a pass may use. cl_scene(uv) (the picture so far, linear filtering), cl_uniform(i) and the names you declared in uniforms (up to 16 floats), cl_texture(i, uv) for up to 4 of your textures (textures = { "horror:textures/misc/noise.png" }), cl_resolution() (pixels), cl_time() (seconds) and cl_partial(). Plain GLSL 4.60 otherwise: functions, for loops, #define, the built-in maths.
What it may not. The engine checks the source before it compiles it: no layout, uniform, buffer, shared, images, atomics, #extension, #include, while / do loops, ##, and no names of the engine’s own resources (Textures, instances, …) or names starting with sc_ / gl_ (except gl_FragCoord and gl_FrontFacing). A shader can only see what the cl_* functions give it, so it cannot read other GPU memory. Compile errors come back from render_post_pass as CL_E_PARSE with the compiler’s message and your line numbers.
Register shaders at setup. The first compile of a mod shader (post pass or model shader) takes about 0.1 to 0.3 s on the main thread; the compiled shader is reused afterwards. Call render_post_pass and model_set_shader in lifecycle.client_setup (or behind a loading screen), not in the middle of play, and switch effects with post_pass_set_enabled instead of registering them again.
In C:
cl_post_pass_desc d = CL_POST_PASS_DESC_INIT;
d.id = CL_STR("dread");
d.shader = CL_STR("horror:shaders/post/dread.glsl");
d.uniforms = CL_STR("fear");
cl_post_pass dread;
cl_render_post_pass(&d, &dread);
/* in a render.frame handler: */
cl_post_pass_set_uniform(dread, CL_STR("fear"), fear);
2. Shader packs
With a shader pack the world is the pack’s, and your post passes still run on its output. Packs can also read your values directly:
cl.render.set_uniform("fear", current_fear) -- a pack sees `uniform float horror_fear;`
and every loaded mod is a define, so a pack can add support for your mod:
#ifdef MOD_HORROR
uniform float horror_fear;
#endif
3. Fog, sky and light
render.fog hands you the fog of this frame before it is drawn; change any field:
cl.on("render.fog", function(ev)
if current_fear <= 0 then return end
local k = current_fear
ev.render_distance_end = ev.render_distance_end * (1 - 0.8 * k) -- blocks
ev.render_distance_start = math.min(ev.render_distance_start, ev.render_distance_end * 0.2)
ev.red, ev.green, ev.blue = ev.red * (1 - k), ev.green * (1 - k), ev.blue * (1 - k)
end)
Fields: fog_type (0 none / atmospheric, 1 water, 2 lava, 3 powder snow, 4 blindness, 5 darkness), environmental_start / environmental_end, render_distance_start / render_distance_end, sky_end, cloud_end (all blocks), red, green, blue, alpha (0…1), partial. The fog only changes what is drawn: chunks still load and cull like vanilla.
cl.render.set_sky_color("#606060", 0.7) -- blend the sky towards grey
cl.render.set_lightmap_tint("#80A0FF", 0.4) -- moonlit blue light
cl.render.set_ambient_darkness(0.3) -- darker everywhere, like the darkness effect
cl.render.clear_overrides() -- back to vanilla
Several mods combine in load order.
4. Camera effects
cl.camera.set_effects{
fov_mult = 0.9, -- a tighter view
roll = 4, -- degrees around the view axis
bob_scale = 1.5, -- heavier view bobbing
vignette = 0.6, vignette_color = 0x300000,
shake = 1.5, shake_speed = 6,
smoothing = 0.5, -- seconds to reach the new values
}
cl.camera.clear_effects()
Effects respect the player’s accessibility settings: the FOV change is scaled by FOV Effects, roll and shake by Distortion Effects, the bobbing scale applies only with View Bobbing on, and shake never happens unless the player turned on “Mod Camera Shake” (Accessibility, off by default; it shows only while mods are loaded). Several mods combine: FOV and bobbing multiply, roll, shake and vignette add. cl.camera.get_effects() returns what applies after all that. The camera’s position and the player’s aim do not change, and the culling frustum turns with the roll, so nothing at the screen corners goes missing.
5. Per-entity render parameters and material variants
A glTF model can have materials for several looks (KHR_materials_variants: clean, bloody, burnt). Pick one per entity, on the server (every Celestial client with CLight sees it) or on a client (only there):
creature:set_material_variant("bloody")
cl.log(table.concat(model:variant_list(), ", ")) -- clean, bloody
For smooth changes, give the model a custom fragment shader and drive it with render parameters:
// assets/horror/shaders/model/blood.glsl
vec4 cl_shade() {
vec4 skin = cl_albedo();
float mask = cl_texture(0, cl_uv()).r; // where blood can show
float blood = clamp(cl_param(0) * 1.2 - (1.0 - mask), 0.0, 1.0);
skin.rgb = mix(skin.rgb, vec3(0.35, 0.0, 0.02), blood);
return cl_light(skin); // the model's normal lighting, fog and hurt flash
}
cl.on("lifecycle.client_setup", function()
local m = cl.model.load("horror:models/entity/tenant.glb")
m:set_shader{ shader = "horror:shaders/model/blood.glsl", params = { "horror:blood" },
textures = { "horror:textures/entity/tenant_blood_mask.png" } }
end)
-- on the server, when it is hurt:
creature:set_render_param("horror:blood", 0.4)
A render parameter is the entity’s synced number under the same key, so e:set_synced("horror:blood", 0.4) does the same. On a client, e:set_render_param sets a local value that wins until e:clear_render_param.
The functions a model shader can call: cl_albedo() (the material’s texture), cl_tint() (base colour times the entity tint), cl_uv(), cl_normal() (with the normal map), cl_view_pos(), cl_lightmap(), cl_time() (seconds of game time), cl_param(0..7), cl_texture(0..3, uv), cl_light(color) (the model’s own lighting, overlay, specular and fog: the same picture as without a shader), cl_fog(color), cl_cutout(). The same source rules as post passes apply. Up to 32 models can have their own shader; shader packs draw such models with their own entity program instead.
Try it
Make the screen pulse red for a second whenever the local player is hurt (entity.damage is a server event, so watch the health on the client).
Show a solution
local pulse, last = 0, nil
local pass = cl.render.post_pass{
id = "hurt", shader = "horror:shaders/post/hurt.glsl",
uniforms = function() return { amount = pulse } end,
}
cl.on("tick.client_post", function()
local p = cl.entity.client_player()
local h = p.health
if last and h < last then pulse = 1 end
last = h
pulse = math.max(0, pulse - 0.05)
end)
with hurt.glsl:
vec4 cl_post(vec2 uv) {
vec4 c = cl_scene(uv);
float edge = smoothstep(0.2, 0.7, length(uv - 0.5));
return vec4(mix(c.rgb, vec3(0.8, 0.0, 0.0), edge * amount * 0.6), 1.0);
}