Elysium scripting Editor API index
Build

Shaders

Your own pixel shaders, written in HLSL, applied to anything the canvas draws.

Shapes, text and images are normally drawn with the cheat's own shader. A script can swap it for its own pixel shader for a while - to animate a gradient, cut a ring out of a square, tint or distort what is behind a panel, or draw something no combination of rectangles can.

Here is a complete example: a bar across the top of the screen whose colour travels from one end to the other.

banner.lua
local source = [[
float4 main( PS_IN i ) : SV_TARGET
{
	float across = i.pos.x / info.z;                              // 0..1 over the screen
	float wave   = 0.5 + 0.5 * sin( across * 12.566 + info.x * 1.5 );
	float3 tint  = lerp( p[0].rgb, p[1].rgb, wave );

	return float4( tint, p[0].a ) * i.col;
}
]]

local banner = render.shader( source )

banner:set( 0, color( 168, 85, 247 ) )
banner:set( 1, color( 56, 189, 248 ) )

on( "frame", function( canvas )
	if not banner:ready( ) then
		return
	end

	canvas:shader( banner )
	canvas:fill( 0, 0, canvas:screen( ), 6, color( 255, 255, 255 ) )
	canvas:shader( nil )
end )

#What you write

You write one function, main, and any helper functions it needs before it. The runtime puts the declarations below in front of your source, so everything in the table further down is simply there:

Texture2D tex0 : register( t0 );
Texture2D backdrop : register( t1 );
SamplerState smp : register( s0 );

cbuffer params : register( b1 )
{
	float4 p[ 8 ];
	float4 info;
};

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

Your function has the signature float4 main( PS_IN i ) : SV_TARGET. It is called for every pixel of every shape drawn while the shader is selected, and returns the colour of that pixel - red, green, blue, alpha, each 0 to 1. The result is blended onto the screen like any other shape.

The language is HLSL for shader model 4 (ps_4_0). There is no #include, the source is limited to 64 KB, and the entry point is always called main. A #line 1 directive sits between the declarations and your code, so the line numbers in error messages are the line numbers of your source.

#What the shader receives

NameTypeWhat it is
i.posfloat4The pixel's position on screen, in pixels: i.pos.x from the left, i.pos.y from the top.
i.uvfloat2Texture coordinates. On a plain, unrounded rectangle from c:fill they run from 0 to 1 across it, which makes them a ready-made local coordinate. See below for other shapes.
i.colfloat4The colour the shape was drawn with, 0 to 1. Multiply your result by it and the colour argument of the draw call keeps working as a tint and as a fade.
tex0, smpTexture2D, SamplerStateThe texture the shape is drawn with - a white pixel for plain shapes, the picture for c:image, the glyph atlas for text - and a linear, clamping sampler.
p[0] .. p[7]float4Eight parameters you set from Lua with s:set.
infofloat4x seconds since the first shader was used, y the last frame's duration, z and w the screen's width and height in pixels.
backdropTexture2DWith { backdrop = true }: a copy of the screen as it was before this shape was drawn. Sample it with uv = i.pos.xy / info.zw.

About i.uv. For a rectangle drawn with c:fill and no rounding the coordinates run 0 to 1 across the shape, and for an image they cover the texture. For rounded shapes, outlines, lines and text they are whatever the cheat's own shader needed - not a position you can rely on. When an effect has to work on any shape, build it from i.pos.

#Using a shader

#render.shader( source [, options] ) → shader

Creates a shader from HLSL source. Nothing is compiled yet: that happens on the first frame the shader is needed, which costs a few milliseconds, so make shaders once, when the script loads, and not in a handler.

  • source - string, up to 64 KB.
  • options.backdrop - true if the shader reads backdrop. Drawing with such a shader takes a copy of the screen first, which is not free; leave it off when you do not need it.
local tint = render.shader( [[ float4 main( PS_IN i ) : SV_TARGET { return float4( 1, 0.5, 0, 1 ) * i.col; } ]] )

#s:set( slot, x [, y [, z [, w]]] )

Sets one of the eight parameters, p[slot] in the shader. slot is 0 to 7; anything you leave out is 0.

A color or a vec3 can be given whole instead of four numbers: a colour arrives as rgba scaled to 0..1, a vec3 as xyz with an optional fourth number.

s:set( 0, 0.5 )                           -- p[0] = ( 0.5, 0, 0, 0 )
s:set( 1, color( 168, 85, 247 ) )         -- p[1] = ( 0.66, 0.33, 0.97, 1 )
s:set( 2, vec3( 1, 2, 3 ), 4 )            -- p[2] = ( 1, 2, 3, 4 )

#s:ready() → boolean

Whether the shader has compiled and can be used. Until then drawing with it is ignored - the shape is simply drawn with the normal shader, so check ready() first if that would look wrong.

#s:error() → string | nil

The compiler's message if the shader did not compile, otherwise nil. The same message goes to the Scripts tab output, with line numbers from your source. A shader that failed stays failed; fix the source and reload the script.

#c:shader( shader | nil )

Everything the canvas draws after this call - until the next c:shader call or until the callback ends - is drawn with the shader. c:shader( nil ) goes back to the normal one. A shader that is not ready is ignored.

#Recipes

#A progress ring

A ring cut out of a square, filled clockwise from the top to a fraction you pass in. It uses i.uv, so the square has to be drawn without rounding.

local ring = render.shader( [[
float4 main( PS_IN i ) : SV_TARGET
{
	float2 d = i.uv - 0.5;
	float r  = length( d ) * 2.0;                                  // 0 centre .. 1 edge of the square
	float shape = smoothstep( 0.78, 0.80, r ) - smoothstep( 0.98, 1.0, r );

	float a = atan2( d.x, -d.y );                                  // 0 at the top, clockwise
	if ( a < 0.0 ) a += 6.2831853;
	float done = step( a / 6.2831853, p[0].x );                    // p[0].x = progress

	float3 tint = lerp( p[2].rgb, p[1].rgb, done );
	return float4( tint, shape ) * i.col;
}
]] )

ring:set( 1, color( 168, 85, 247 ) )      -- the finished part
ring:set( 2, color( 60, 50, 80 ) )         -- the rest

on( "frame", function( canvas )
	if not ring:ready( ) then return end

	ring:set( 0, ( math.sin( render.time( ) ) + 1 ) / 2 )

	canvas:shader( ring )
	canvas:fill( 40, 40, 64, 64, color( 255, 255, 255 ) )
	canvas:shader( nil )
end )

#Frosted, desaturated glass

A shader that reads the backdrop: it shows what is behind it with its colour drained, as far as p[0].x says (0 untouched, 1 grey).

local glass = render.shader( [[
float4 main( PS_IN i ) : SV_TARGET
{
	float2 uv     = i.pos.xy / info.zw;
	float3 behind = backdrop.Sample( smp, uv ).rgb;
	float grey    = dot( behind, float3( 0.299, 0.587, 0.114 ) );

	return float4( lerp( behind, grey.xxx, p[0].x ), 1.0 );
}
]], { backdrop = true } )

glass:set( 0, 0.85 )

on( "frame", function( canvas )
	if not glass:ready( ) then return end

	canvas:shader( glass )
	canvas:fill( 40, 200, 220, 120, color( 255, 255, 255 ) )
	canvas:shader( nil )
end )

#A vignette over the whole screen

A transparent black darkening the corners. The shader returns a colour with its own alpha, and the white rectangle only provides the area to cover.

local vignette = render.shader( [[
float4 main( PS_IN i ) : SV_TARGET
{
	float2 d = i.pos.xy / info.zw - 0.5;
	d.x *= info.z / info.w;                                        // round, not oval
	float v = smoothstep( 0.35, 0.95, length( d ) );

	return float4( 0.0, 0.0, 0.0, v * p[0].x ) * i.col;
}
]] )

vignette:set( 0, 0.6 )

on( "frame", function( canvas )
	if vignette:ready( ) then
		local width, height = canvas:screen( )

		canvas:shader( vignette )
		canvas:fill( 0, 0, width, height, color( 255, 255, 255 ) )
		canvas:shader( nil )
	end
end )

#Limits and gotchas

Elysium scripting guide · built 2026-10-03