Skip to content

Custom shader mods#

Back to shader guides.

Place .hlsl / .vs.hlsl files under each mod’s assets/ folder (see shader names below). The engine stitches your HLSL into Rainfall’s generated shaders. Toggle shaders from the dev Shader Mod Loader, or drive them from a Luau mod via Courage.Shaders.

Fragment vs vertex#

Mods are either fragment (.hlsl) or vertex (.vs.hlsl). Fragment mods run once per pixel after rasterization. They fit color grading, screen-space effects, and anything that reads or writes the lit color in prev or texture UVs. Vertex mods run once per vertex before transform and skinning. They fit mesh deformation, outline hull extrusion, and changes to position, normal, or vertex colors before lighting and TEV run.

Use fragment mods when the effect depends on final or per-pixel color, screen position, or texture UVs. Use vertex mods when the effect must change positions, normals, or per-vertex colors before lighting and TEV run.

Hook tags#

Hook tags are comment markers left in generated shader source. Rainfall finds them and inserts your mod code right after the tag.

Vertex#

All .vs.hlsl mods inject at // INJECT_BEFORE_TRANSFORM. There is no hook selector for vertex mods, and the engine always uses this single point. Injection runs after attributes are copied into mutable locals (position, normal, color0, color1) and before model-view, bone skinning, and projection.

Fragment#

Fragment mods default to the last stage if no hook is defined. Use // TARGET: HOOK_NAME at the top of your .hlsl file to pick an earlier injection point. The GameCube TEV pipeline builds color in stages: raster color, texture combiners, fog, then the framebuffer.

Tag Pipeline position When to use
// INJECT_BEFORE_TEV After texUV*, rasColor, and TEV register init, before any TEV stage Tint rasColor, warp texUV0 before texture combiners, per-stage setup
// INJECT_BEFORE_FOG After all TEV stages and alpha test, before fog is applied to prev Adjust lit/textured color before distance fog blends in
// INJECT_BEFORE_RETURN After fog and destination alpha, before fragColor = prev Full-screen color effects, rims, grading (default if TARGET omitted)

Example target line (place once at the top of the mod):

// TARGET: INJECT_BEFORE_TEV

Supported aliases in the parser: // TARGET: INJECT_BEFORE_TEV or // TARGET INJECT_BEFORE_TEV (optional colon or space after TARGET).

At INJECT_BEFORE_FOG and INJECT_BEFORE_RETURN, prev already includes TEV output (and alpha test has run). Fog is blended into prev only after INJECT_BEFORE_FOG.

Available variables#

What you can touch depends on the stage.

Vertex (INJECT_BEFORE_TRANSFORM)

Symbol Type Notes
position float3 Model-space position, modify for displacement
normal float3 Model-space normal
color0, color1 float4 Vertex colors passed to fragment stage
u_time float Seconds since startup (State uniforms)
u_hasBones uint Non-zero on skinned draws
a_boneIdx uint Bone index per vertex (skinned only)
u_projection mat4 Use [3][3] for perspective vs ortho guard

Fragment (all hooks)

Symbol Typical use
input.position xy screen UV helpers via _timePad, w for 3D guard
prev Main color accumulator (RGBA)
u_time, _timePad Animation and 1/width, 1/height

Fragment (INJECT_BEFORE_TEV)

Symbol Notes
rasColor Rasterized vertex color for current TEV stage
texUV0 ... texUV7 Texture coordinates (as many as the material uses)
tevReg0, tevReg1, tevReg2 TEV constant registers
fragZ Depth for Z-texture paths

Gating 3D vs HUD / 2D#

The engine does not inject an is3D flag. Add a guard in every mod that should skip UI, text, or orthographic passes:

  • Fragment mods:
    bool is3D = input.position.w != 1.0f;
    if (is3D) { /* ... */ }
    
  • Vertex mods:
    if (u_projection[3][3] == 0.0f) { /* ... */ }
    

Mod shader control (Courage.Shaders)#

Mods can enable, disable, list, reload, and register custom HLSL at runtime. The game exposes a small API on top of the same loader the dev menu uses.

Loading the module#

local Shaders = require("Courage.Shaders")
-- or:
local Game = require("Courage.Game")
local Shaders = Game.shaders
import * as Shaders from "Courage.Shaders"
// or:
import * as Game from "Courage.Game"
const shaders = Game.shaders

require("Courage.Shaders") is only available after the game registers host modules on the shared Luau VM (at startup, before your mod entry file runs).

Shader names#

On disk, put fragment mods in mods/<mod-id>/assets/ (recursive). Vertex mods use the .vs.hlsl extension.

File on disk Registered name
mods/shader_demo/assets/red_tint.hlsl shader_demo/red_tint
mods/shader_demo/assets/filters/crt.hlsl shader_demo/filters/crt
mods/shaders/global_fx.hlsl global_fx (no mod prefix)

set_enabled and list() always use these full logical names (mod-id/path/stem).

While your mod entry file and init run, require("Courage.Assets") resolves a file under assets/ to that logical name. See Courage.Assets.

local Shaders = require("Courage.Shaders")
local Assets = require("Courage.Assets")

-- mod id: shader_demo, file: assets/red_tint.hlsl
local name = Assets("red_tint.hlsl")  -- "shader_demo/red_tint"
Shaders.set_enabled(name, true)
import * as Shaders from "Courage.Shaders"
import * as Assets from "Courage.Assets"

// mod id: shader_demo, file: assets/red_tint.hlsl
const name = Assets("red_tint.hlsl")  // "shader_demo/red_tint"
Shaders.set_enabled(name, true)

Quick start#

local Shaders = require("Courage.Shaders")

-- Enable a shader discovered from disk (see shader names above)
Shaders.set_enabled("shader_demo/red_tint", true)
Shaders.apply()   -- push enabled set to Rainfall / GPU

Shaders.reload()  -- rescan mods/ and assets/, preserves prior on/off state by name
import * as Shaders from "Courage.Shaders"

// Enable a shader discovered from disk (see shader names above)
Shaders.set_enabled("shader_demo/red_tint", true)
Shaders.apply()   // push enabled set to Rainfall / GPU

Shaders.reload()  // rescan mods/ and assets/, preserves prior on/off state by name

set_enabled only updates the in-memory selection. Call apply() after you change which shaders are enabled, otherwise nothing reaches the renderer.

Listing and indexing#

local Shaders = require("Courage.Shaders")

-- Convenience table (1-based array)
for i, entry in ipairs(Shaders.list()) do
    print(i, entry.name, entry.vertex, entry.enabled)
end

-- Lower-level accessors (indices are 1-based in Luau)
local n = Shaders.count()
local name = Shaders.name(1)
local isVert = Shaders.is_vertex(1)
local on = Shaders.is_enabled(1)
import * as Shaders from "Courage.Shaders"

// Convenience array (1-based when indexed via Shaders.name(1), etc.)
for (const [i, entry] of Shaders.list().entries()) {
    print(i + 1, entry.name, entry.vertex, entry.enabled)
}

// Lower-level accessors (indices are 1-based)
const n = Shaders.count()
const name = Shaders.name(1)
const isVert = Shaders.is_vertex(1)
const on = Shaders.is_enabled(1)

Each list() entry is { name = string, vertex = boolean, enabled = boolean }.

Register HLSL at runtime#

Register sources without a file on disk. When called from a mod’s entry script, the two-argument form prefixes the active mod's id from mod.json (same as disk discovery).

local Shaders = require("Courage.Shaders")
local Assets = require("Courage.Assets")

local fsSource = [[
bool is3D = input.position.w != 1.0f;
if (is3D) { prev.r = 1.0; prev.g *= 0.5; prev.b *= 0.5; }
]]

-- registers as "<mod-id>/runtime_tint"
local index = Shaders.register_fragment("runtime_tint", fsSource)
Shaders.set_enabled(Assets("runtime_tint.hlsl"), true)  -- or hard-code "<mod-id>/runtime_tint"
Shaders.apply()
import * as Shaders from "Courage.Shaders"
import * as Assets from "Courage.Assets"

const fsSource = `
bool is3D = input.position.w != 1.0f;
if (is3D) { prev.r = 1.0; prev.g *= 0.5; prev.b *= 0.5; }
`

// registers as "<mod-id>/runtime_tint"
const index = Shaders.register_fragment("runtime_tint", fsSource)
Shaders.set_enabled(Assets("runtime_tint.hlsl"), true)  // or hard-code "<mod-id>/runtime_tint"
Shaders.apply()

Vertex mods use register_vertex(name, source) (or the three-argument form register_vertex(modId, name, source)).

register_* returns a 1-based index into the shader list. Re-registering the same full name replaces the stored source in place.

API reference#

See Courage.Shaders for the full function table.

Loading a shader from a mod#

mods/shader_demo/mod.json:

{
  "id": "shader_demo",
  "type": "luau",
  "entry": "main.luau",
  "version": "1.0.0",
  "hostVersion": "0.8.0",
  "engineVersion": "1.0.0"
}

mods/shader_demo/assets/red_tint.hlsl, fragment mod (see Examples for HLSL body).

mods/shader_demo/main.luau, enable the tint when the mod loads:

local Shaders = require("Courage.Shaders")

Shaders.reload()  -- pick up assets/red_tint.hlsl
Shaders.set_enabled("shader_demo/red_tint", true)
Shaders.apply()
import * as Shaders from "Courage.Shaders"

Shaders.reload()  // pick up assets/red_tint.hlsl
Shaders.set_enabled("shader_demo/red_tint", true)
Shaders.apply()

To toggle shaders in response to gameplay, call the same three functions from an Augment.Hook callback (for example after a stage-load or menu function you care about).