Shaders¶
Mods ship GLSL source. The engine compiles it at load with in-process
glslang in the vulkan1.0 dialect, caches the SPIR-V by content hash
under <mod>/.build/shaders/, and writes compile errors to
.build/shaders/<name>.log. A broken shader yields an unavailable effect
and a log line; the mod still loads.
Shaders run on the GPU and cannot touch guest RAM. C code may read game state to feed uniforms; nothing flows back.
| Tier | API | Effect |
|---|---|---|
| Post-process | shader_register, postfx_register, postfx_enable, postfx_set_uniforms |
Fullscreen fragment passes over the composed frame: CRT simulation, grading, scanlines |
| Material | [[material]] rows, or material_register |
Replaces the fragment shading of a subset of the engine's own draws: a moby class, a terrain tier, the flame plume. The manifest form needs no C |
A mod shades what the engine draws; it cannot add draws of its own.
Registering a shader¶
int frag = api->shader_register(self, "shaders/crt.frag");
The path is relative to the mod and the extension selects the stage.
Keep shaders under shaders/: that directory is the one hot reload
watches under OPENPETE_MOD_HOT_RELOAD=1. The call returns -1 for a
missing file or a compile error.
Every shader includes the binding contract:
#version 450
#extension GL_GOOGLE_include_directive : require
#define OP_POSTFX_STAGE_FRAGMENT 1 /* post-process fragments only: defines op_uv, op_color */
#include "openpete_shader_api.glsl"
The include resolves to <exe>/sdk/shaders/openpete_shader_api.glsl,
and its bytes are hashed into every shader's build key, so an interface
change invalidates the cache. Material shaders include
openpete_psx_material.glsl or openpete_psx_decal.glsl instead; never
both, since each claims the same descriptor set.
To ship without source, place <path>.spv where no source sits beside
it, with a <path>.spv.ihash sidecar matching the current interface
hash. A stale sidecar refuses the shader.
Post-process passes¶
int fx = api->postfx_register(self, frag, OP_POSTFX_COMPOSITE, /*order*/ 0);
api->postfx_set_uniforms(self, fx, ¶ms, sizeof params); /* copied; any context */
api->postfx_enable(self, fx, 1);
Injection points:
OP_POSTFX_COMPOSITE: after the scene and the HUD are composed, before the present blit. Sees the whole frame including the HUD.OP_POSTFX_SCENE: after the 3D scene, before the HUD, so grading and fog tint the world while the HUD stays untouched. Receives scene depth atop_scene_depth.OP_POSTFX_PRESENT: reserved; registration is refused.
Passes run on every present, canonical and sub-tick. Order across mods
follows enabled-list priority; order breaks ties within one mod, lower
first.
Inputs: op_prev_pass (set 0), op_scene_depth (set 1; the opaque
terrain pre-pass depth, meaningful at the SCENE point, no mobys),
op_prev_frame (set 2, for temporal effects). The engine block
op_engine at set 3 binding 0 carries time, the tick, the interpolation
alpha (the value present hooks see), the resolutions, and the aspect
ratio. The mod's own block is set 3 binding 1: declare it std140 and
mirror the layout in the C struct by hand. postfx_set_uniforms copies
the bytes verbatim, at most 1024.
Example: crt-beans (a three-pass CRT model, shipped under
sdk/examples/).
Materials¶
A material replaces the fragment shading of a subset of the geometry the engine draws. A selector names the subset; the engine decides per draw whether the shader runs. The shader never receives game structs and never decides whether it applies.
A material inherits its draw group's blend mode, depth policy, and draw order and changes colour math only. It cannot move, reorder, hide, or re-composite geometry. On an additive draw the output is still added.
Declare a material in the manifest¶
Up to 16 rows per mod.
[[material]]
frag = "shaders/tint.frag"
channel = "player"
params = [1.0, 0.55, 0.15, 0.45] # the shader's std140 block
Every selector key is optional; an omitted key matches everything:
| Key | Meaning |
|---|---|
frag, vert |
Shader paths relative to the mod |
channel |
any, moby, terrain, player, flame, particle, sky, shadow, tracer, portal |
ident |
Channel-local identity: flame ribbon (0) or caps (1); particle (kind << 8) \| sel with kinds 0 quads, 1 lines, 2 glow rings, 3 sparkle flares, 4 sparkle lines, 5 dragon star (OP_PART_*); shadow caster kind; portal preview kind |
class |
Moby class |
texture |
Terrain texture-record ID, 0 to 127 |
level |
Level ID. Texture IDs are per level, so a texture row usually needs this |
tier |
"hp" or "lp"; omit for both |
textured |
true or false; omit for both |
env_anim |
true selects only animated terrain env faces |
params |
Floats handed to the shader as its uniform block |
enabled |
false ships the row switched off |
A key the engine does not recognise is refused with an error, since a
typo that silently widened a selector would repaint geometry the row
never named. declarative-example is a complete example: one manifest,
asset files and one .frag.
Terrain is drawn two ways: near and changing geometry is walked per
present; the rest is retained in GPU buffers. The engine compiles a
material's .frag once per path and gates both with the same occlusion
verdicts, so one shader covers both, and a terrain material does not
reduce the retained set.
Register a material from C¶
openpete_mod_material_selector_t sel = openpete_mod_material_sel_any();
sel.channel = OP_CHAN_MOBY;
sel.moby_class = 42;
int mat = api->material_register(self, &sel, frag);
api->material_set_params(self, mat, &block, sizeof block); /* std140, at most 256 bytes */
api->material_enable(self, mat, 1);
Build the selector with openpete_mod_material_sel_any() and assign
fields; never zero-initialise it. The struct carries its own size and
flag mask, and the engine refuses a selector it cannot honour exactly.
Manifest rows arm before the entry point runs, so C registrations land later and win where two materials claim the same pixels.
material_register is refused inside a present, and a UI section runs
inside one. A material that follows a value the player is changing
retargets with material_set_selector instead:
sel.moby_class = picked;
api->material_set_selector(self, mat, &sel);
The handle, shader, refine callback, and enabled state survive. From a present hook or UI section the change applies at the next tick, since selection is constant across a present; the material keeps its old key until then.
api->moby_classes(self, buf, cap) returns the moby classes the current
level has loaded, from an engine-side snapshot refreshed once per tick,
so it is safe from a UI section and gives a class picker no dead entries.
Write a material shader¶
#include "openpete_psx_material.glsl"
layout(set = 3, binding = 1, std140) uniform op_instance { vec4 tint; };
vec4 op_material(vec4 base) // base = the engine's own result
{
return vec4(mix(base.rgb, tint.rgb, tint.a), base.a);
}
base is the face as the engine would draw it: CLUT decode, perspective
divide, PSX modulation, and any installed HD texture replacement applied.
For untextured draws it is the interpolated vertex colour. Return
base.a unchanged unless the material means to change transparency; the
engine discards the PSX transparent-texel sentinel before the call.
The engine owns main(). The stock fragment stage also runs the
per-pixel surface verdicts that decide which surface exists at a pixel; a
shader that owned main() would replace those verdicts and break
whenever the engine changed them.
Moby drop-shadows are drawn on their own path (flat fans, depth test off,
per-pixel occlusion decided in the fragment stage). A material on that
path includes openpete_psx_decal.glsl instead; the function and body
are unchanged and the engine picks the include from the draw path. The
occlusion verdict is applied before op_material runs. Shadows blend
subtractively, so a brighter tint darkens the ground more.
mods/channel-gallery registers one highlight shader once per channel
with a different selector each time, toggled live; it shows what each
selector claims.
Refine callbacks¶
A selector prunes cheaply; a refine callback decides per instance. It
runs during extraction in tick context and returns the instance's uniform
block, or -1 to leave the instance stock:
static int refine(const openpete_mod_material_key_t* key, void* out, uint32_t cap) {
const Moby* m = (const Moby*)api->guest(key->u.moby.vaddr);
if (!m || m->m_State != BURNING) return -1;
float* v = out; v[0] = 1.0f; v[1] = 0.4f; v[2] = 0.0f; v[3] = 1.0f;
return 16;
}
...
api->material_register_refine(self, &sel, frag, refine);
Available on the moby channel (keyed per instance) and on terrain (keyed per texture ID or the untextured band, asked once per present). Scope the selector as tightly as possible first; the callback is the expensive half.
Results are memoised per instance and the call count is not part of the
contract, so the callback must be a pure function of its key and
tick-stable guest state. Instances whose blocks are byte-identical share
one slot and one batch, so cost follows the number of distinct outcomes,
not the number of matches; one distinct block per instance is the
worst case. channel-gallery (shipped under sdk/examples/) registers
materials from C over every selectable surface.