Violet

Shaders

On this page

core.render can draw any shape, text or quad through a pixel shader you write in HLSL. Compile it once with core.render.create_shader, then either pass the handle in the shader option of any drawing call, or draw a bare quad with core.render.shader_rect and let the shader paint every pixel of it. Shaders run per pixel on the graphics card, so effects that would take thousands of shapes (soft glows, scanlines, animated outlines, distorting the game frame) cost a single call.

lua
local pulse = assert(core.render.create_shader([[
float4 main(PS_INPUT input) : SV_Target
{
    float2 uv = shape_uv(input.pos);                 // 0..1 across the shape
    float d = distance(uv, float2(0.5, 0.5)) * 2.0;  // 0 at the centre, 1 at the edge
    float radius = 0.6 + 0.4 * sin(time.x * 4.0);
    float ring = smoothstep(0.08, 0.0, abs(d - radius));
    return float4(input.col.rgb, input.col.a * ring);
}
]]))

function on_render()
    local w, h = core.render.screen_size()
    core.render.shader_rect(pulse, w / 2 - 100, h / 2 - 100, 200, 200, { color = {255, 80, 200} })
end
The GPU is not sandboxed

A shader runs on the graphics card. An infinite loop or an extremely expensive shader can stall the driver; Windows resets it after a few seconds, which takes the game down with it. Keep loops bounded, avoid dependent texture reads in loops, and try a new shader on a small quad first.

Writing a shader

A shader is an HLSL pixel shader whose entry point is float4 main(PS_INPUT input) : SV_Target, returning the pixel's colour with straight (non-premultiplied) alpha. The following prelude is prepended to your source, so these names are always available:

struct PS_INPUT
{
    float4 pos : SV_POSITION;
    float4 col : COLOR0;
    float2 uv  : TEXCOORD0;
};

cbuffer OverlayParams : register(b0)
{
    float4 bounds;
    float4 resolution;
    float4 time;
    float4 params[16];
};

Texture2D    tex0           : register(t0);
SamplerState samp0          : register(s0);
Texture2D    screen         : register(t1);
SamplerState screen_sampler : register(s1);

float  param(int i)          { return params[i >> 2][i & 3]; }
float2 shape_uv(float4 pos)  { return (pos.xy - bounds.xy) / max(bounds.zw, 1.0); }
float2 screen_uv(float4 pos) { return pos.xy * resolution.zw; }

Compiler messages use your own line numbers; the prelude does not shift them. Pass raw = true to create_shader to skip the prelude and declare everything yourself (your PS_INPUT must then match the layout above).

Inputs

FieldMeaning
input.posThe pixel's position in screen pixels (SV_POSITION)
input.colThe color option as 0..1 RGBA
input.uvTexture coordinates: 0..1 across a shader_rect quad, glyph coordinates for text, a constant for plain shapes. Use shape_uv(input.pos) for a 0..1 coordinate inside any shape.

Constants

NameTypeMeaning
boundsfloat4x, y, width, height of the shape being drawn, in screen pixels
resolutionfloat4width, height, 1/width, 1/height of the window
timefloat4seconds since the overlay started, seconds since the previous frame, frame index, 0
paramsfloat4[16]the params option, packed four per element; param(i) reads the i-th number (0-based)

Textures

  • tex0 / samp0 is what the shape is drawn with: the font atlas for text and plain shapes, a white pixel for shader_rect. Multiply by it to keep text readable: tex0.Sample(samp0, input.uv) * input.col.
  • screen / screen_sampler is a copy of the game frame beneath the overlay, available only when the drawing call sets screen = true. Sample it at screen_uv(input.pos). The copy is taken before the overlay draws, so other overlay shapes are not in it.

Blending

By default the shader's output is alpha-blended over the frame. blend = "add" adds it instead, which suits glows and light effects.

Functions

core.render.create_shader(source: string, opts?: table) -> shader | nil, string

Compiles an HLSL pixel shader and returns a handle. On failure returns nil and the compiler's messages, formatted like shader(12,5-20): error X3004: undeclared identifier 'foo'. On success a second return value carries any compiler warnings, or nil.

Options:

KeyDefaultNotes
entry"main"entry point function name
profile"ps_4_0""ps_4_0", "ps_4_1" or "ps_5_0"
rawfalseskip the prelude

Compiling happens on the spot and can be done from any callback or at the top of the script, which is the recommended place. Limits: 64 KB of source, 32 shaders per script, 256 in total. Shaders are released when the script unloads.

lua
local shader, err = core.render.create_shader(source)
if not shader then
    core.log_error("shader failed to compile:\n" .. err)
    return
end

shader:is_valid() -> boolean

true until the shader is released or its script is unloaded.


shader:release() -> nil

Frees the shader early. Passing the handle to a drawing call afterwards raises an error, and shader_rect returns nil, reason.


core.render.shader_rect(shader: shader, x: number, y: number, w: number, h: number, opts?: table) -> boolean | nil, string

Draws a quad through the shader, with input.uv running from 0, 0 at the top-left to 1, 1 at the bottom-right. Takes the same options as the other drawing calls (color, params, screen, blend, world, anchor). Only inside on_render().

A color with alpha 0 draws nothing: the quad is discarded before the shader runs. Keep the alpha above zero and compute any transparency in the shader. A quad whose shader cannot run (released handle, rejected by the graphics device) is skipped rather than drawn as a plain box.

lua
core.render.shader_rect(scanlines, 0, 0, w, h, { screen = true, params = { 3.0, 0.15 } })

Shading shapes and text

Pass shader = handle to any drawing call and its primitives, glow passes included, are drawn through it. bounds covers the whole shape, so shape_uv works the same way for a circle, a line or a string of text.

lua
local rainbow = assert(core.render.create_shader([[
float3 hue(float h)
{
    float3 k = float3(0.0, 2.0, 4.0);
    return saturate(abs(fmod(h * 6.0 + k, 6.0) - 3.0) - 1.0);
}

float4 main(PS_INPUT input) : SV_Target
{
    float4 glyph = tex0.Sample(samp0, input.uv) * input.col;
    float phase = shape_uv(input.pos).x + time.x * 0.5;
    return float4(hue(frac(phase)), glyph.a);
}
]]))

function on_render()
    core.render.text(40, 40, "Rainbow", { shader = rainbow, size = 32, shadow = true })
end

Examples

Scanlines over the game frame

lua
local scanlines = assert(core.render.create_shader([[
float4 main(PS_INPUT input) : SV_Target
{
    float3 frame = screen.Sample(screen_sampler, screen_uv(input.pos)).rgb;
    float spacing = max(param(0), 1.0);
    float strength = param(1);
    float band = 0.5 + 0.5 * sin(input.pos.y * 3.14159 / spacing);
    return float4(frame * (1.0 - strength * band), 1.0);
}
]]))

function on_render()
    local w, h = core.render.screen_size()
    core.render.shader_rect(scanlines, 0, 0, w, h, { screen = true, params = { 3.0, 0.25 } })
end

Pulsing outline around each mob

lua
local outline = assert(core.render.create_shader([[
float4 main(PS_INPUT input) : SV_Target
{
    float2 uv = shape_uv(input.pos);
    float2 edge = min(uv, 1.0 - uv);
    float inset = min(edge.x, edge.y) * min(bounds.z, bounds.w);
    float width = 2.0 + 2.0 * sin(time.x * 6.0 + param(0));
    float alpha = smoothstep(width + 1.0, width, inset);
    return float4(input.col.rgb, input.col.a * alpha);
}
]]))

function on_render()
    local map = core.object_manager.get_current_map()
    if not (map and map:is_valid()) then return end

    for _, mob in ipairs(map:get_mobs()) do
        local pos = mob:is_valid() and mob:get_position()
        if pos then
            core.render.shader_rect(outline, pos.x - 40, pos.y - 80, 80, 80, {
                anchor = mob:anchor(), color = {255, 120, 40}, blend = "add",
                params = { (mob:get_unique_id() or 0) % 7 },
            })
        end
    end
end

Troubleshooting

MessageMeaning
shader(3,12-20): error X3004: …HLSL error at line 3 of your source; the compiler lists every error
shader compiler is unavailablethis system has no HLSL compiler, so shaders cannot be used on it
shader limit reached32 shaders for this script or 256 in total already exist; release some
expected a valid shadershader_rect got a handle that was released, or belongs to a script that was unloaded
render: shader is invalid or releasederror raised when the shader option of a drawing call holds such a handle
shader:is_valid() turns false on its ownthe graphics device rejected the compiled shader (for example ps_5_0 on older graphics hardware); recompile with ps_4_0
a shape draws plainly, a quad not at allthe same device rejection, seen from the drawing call that used the handle