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):
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:
- Vertex mods:
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#
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.
Quick start#
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:
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).