Skip to content

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, &params, 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 at op_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.