Skip to content

Authoring a custom-material pack shader

A shader can bind its own textures and parameters — foam/ramp/flow/noise maps, toon ramps, tint colours, scroll speeds — through a fixed custom-material set 5, with zero engine change. This is the mechanism that lets stylized looks ship as content packs: pure .slang + textures + Wren, no engine source (ADR 0073 / PLM-234).

Companion guide: Animating shaders with the frame clock & wind. A real pack shader usually uses both — e.g. a water shader scrolls its own flow map by plume3d_time() and binds its own normal/foam maps at set 5.

The engine reserves set 5 for your material — you never touch engine code, you just declare and read these (all via #include "plume3d.slang"):

set 5 bindingslang (from plume3d.slang)Wren writes it with
0Texture2D Plume3D_CustomTextures[8] — read via plume3d_customTex(slot, uv)mesh.setCustomTexture(slot, texture) — slot 0..7
1SamplerState Plume3D_CustomSampler (shared)— (automatic)
2plume3d_customParams[16] — read via plume3d_customParam(index)mesh.setCustomColor(byteOffset, r,g,b,a) · mesh.setCustomParamFloat(byteOffset, v) · mesh.setCustomParams(list)

Params are 256 bytes = 16 × float4, with byteOffset / 16 = index: setCustomColor(0, …) fills plume3d_customParam(0); setCustomParamFloat(16, …) fills plume3d_customParam(1).x. The layout is your pack’s own convention — the engine just carries the bytes. Full method reference: Mesh → Custom material (set 5).

apps/<pack>/shaders/src/<name>.slang (from the custom-material-pack example):

#include "plume3d.slang"
// ... VSInput / VSOutput as usual; pass a UV to the fragment ...
[shader("fragment")]
float4 fragment(VSOutput input): SV_Target {
float4 tint = plume3d_customParam(0); // params[0] = your tint colour
float scroll = plume3d_customParam(1).x; // params[1].x = your scroll speed
float4 ramp = plume3d_customTex(0, input.UV); // slot 0
float4 flow = plume3d_customTex(1, input.UV + float2(plume3d_time() * scroll, 0)); // slot 1, scrolled
return float4(ramp.rgb * tint.rgb * (0.4 + 0.6 * flow.r), 1.0);
}
import "engine" for Graphics, Mesh, Texture
_mesh.setCustomTexture(0, Texture.load("textures/ramp.png")) // → slot 0
_mesh.setCustomTexture(1, Texture.load("textures/flow.png")) // → slot 1
_mesh.setCustomColor(0, 1.0, 0.55, 0.2, 1.0) // → params[0] tint
_mesh.setCustomParamFloat(16, 0.15) // → params[1].x scroll speed

Any setCustom* call marks the mesh as carrying a custom material; the engine binds set 5 for its draws automatically. mesh.clearCustom() drops it.

A pack is just content dropped into a game’s package (no separate engine format): the folder holds shaders/src/*.slang (plus compiled shaders/bin/*.spv), textures/, .blend models, and Wren helpers. The consuming game references your assets by path. See the reference app apps/custom_material_pack.

Terminal window
plume3d apps/custom_material_pack # self-screenshots, then exits

The reference proof: with a neutral grayscale ramp texture, the output is a warm-tinted gradient. A coloured result from a colourless texture means your tint params reached the shader, and the gradient means your texture is sampled. If you see flat white, the custom set isn’t bound (check that a setCustom* was called before drawing).

  • Slots 0–7 and 256 param bytes are the budget — the engine bounds-checks every setter (an out-of-range slot/offset is ignored). That is the whole set a pack can drive; there is no way to add more bindings from content, by design (this keeps packs engine-change-free and MoltenVK-safe).
  • Read the Wren-scripting rules before writing .wren (engine docs/wren-scripting-gotchas.md).
  • v1 limitations (follow-ups): custom textures sample through the sRGB view — fine for colour ramps; a linear-data variant (for flow/noise/data maps that must not be sRGB-decoded) is planned. The per-frame custom-draw budget is 1024 draws.
  • Custom material is additive and inert for ordinary draws — existing shaders never see set 5.