Shaders¶
MilkDrop 2 added two pixel shaders to every preset:
- the warp shader replaces the fixed-function warp: it reads the previous frame and produces the new canvas;
- the composite shader replaces the fixed-function composite: it turns the canvas into what is displayed, and is never fed back.
Both are written in Direct3D 9-era HLSL (shader model 2 or 3). projectM translates them to GLSL with a vendored HLSL parser and compiles them as GLSL ES 3.00 on ProjectM TV. When translation or compilation fails, the stage silently falls back to a simple built-in shader: the preset loads but looks nothing like itself. A portable shader has to stay within what translates.
Shader lines in the file¶
Each line starts with a backtick, and the lines are numbered from 1 without gaps. Your code may declare samplers, constants and functions before shader_body. ret (float3) is the output colour and starts at zero.
The hidden header¶
Before your code, the engine prepends a header that defines the inputs. Your code uses the names; the storage column shows where each value is held.
| Name | Meaning | Storage |
|---|---|---|
uv |
Texture coordinate of this pixel (warp: after the mesh warp) | _uv.xy |
uv_orig |
Coordinate before warping (warp shader) | _uv.zw |
rad, ang |
Distance from centre and angle, interpolated from the mesh | _rad_ang |
hue_shader |
Per-vertex colour (composite) | _vDiffuse.xyz |
aspect |
Aspect correction: .xy multiply, .zw inverse |
_c0 |
time, fps, frame, progress |
_c2 |
|
bass, mid, treb, vol |
_c3 |
|
bass_att, mid_att, treb_att, vol_att |
_c4 |
|
blur1_min … blur3_max |
Blur ranges | _c5, _c6, _c13 |
texsize |
(width, height, 1/width, 1/height) of the canvas |
_c7 |
roam_cos, roam_sin |
0.5+0.5·cos/sin of time at about 0.3, 1.3, 5 and 20 rad/s | _c8, _c9 |
slow_roam_cos, slow_roam_sin |
The same at about 0.005-0.022 rad/s | _c10, _c11 |
mip_x, mip_y, mip_avg |
Mip-level helpers | _c12 |
q1 … q32 |
The per-frame q values, after per_frame code | _qa.x … _qh.w |
rand_frame, rand_preset |
Random float4: new every frame / once per preset |
|
rot_s1…rot_s4, rot_d1…, rot_f1…, rot_vf1…, rot_uf1…, rot_rand1… |
4×3 matrices (rotation plus translation): static, slow, fast, very fast, ultra fast, random per frame |
Helper functions:
| Function | Does |
|---|---|
GetMain(uv), GetPixel(uv) |
tex2D(sampler_main, uv).xyz |
GetBlur1(uv), GetBlur2(uv), GetBlur3(uv) |
A blur level, decoded back to normal colour (see below) |
lum(c) |
dot(c, float3(0.32, 0.49, 0.29)) |
tex2d and tex3d are accepted as tex2D and tex3D.
Portability
- MilkDrop's
timeinside shaders counts from the preset start (wrapped at 10,000 s). projectM's counts from program start. The per-frame equation variabletimeis program time in both. - In MilkDrop,
rand_frame,rand_presetand therot_s…rot_ufmatrices are shared by the warp and composite shaders (rot_rand1–4are drawn anew for each). projectM generates them separately per shader, so the two stages see different values. - MilkDrop's
volandvol_attin shaders actually hold one third oftrebandtreb_att, because of a comma-operator bug in its source (milkdropfs.cpp:3970). projectM passes the real average volume. - MilkDrop writes the composite's alpha from the vertex colour; projectM writes 1.0.
Blur levels must be decoded¶
Each blur level stores colours normalized to its range, (c − min) / (max − min), with each level's range nested inside the previous level's. GetBlur1(uv) undoes this: it returns tex2D(sampler_blur1, uv).xyz * (max1−min1) + min1. Reading sampler_blur1 directly returns the encoded value, which looks washed out or too dark whenever the range is not 0-1, so use GetBlurN.
Only the blur levels a shader needs are computed. MilkDrop decides from the blur samplers the compiled shader actually uses, upstream projectM searches the text for GetBlurN and sampler_blurN, and ProjectM TV also counts blurN_min/blurN_max. Blur ranges and their safe values are covered under effects.
Writing to inputs¶
q1, time, bass and the others are macros onto uniform components, so an assignment like q18 = 1; or time *= 0.4; writes into a uniform. Microsoft's compiler gave each shader invocation a writable copy that started from the input. Some projectM builds produced an uninitialized copy instead, so other components and other functions read garbage ($$$ Royal - Mashup (324)). ProjectM TV reproduces Microsoft's behaviour. For portability, copy an input into your own local before modifying it.
What translates, and what does not¶
All 15,576 authored warp and composite shaders in the bundled library were translated in an earlier pass. Real presets failed on these constructs:
| Construct | Status in ProjectM TV | Advice for portable presets |
|---|---|---|
A local variable named sample (reserved in GLSL ES) |
Renamed automatically | Avoid the name |
#define macros, including ones that expand to declarations or statements |
Token replacement, keeping your spacing and parentheses | Put grouping parentheses inside the macro if precedence matters |
Swizzle or index after a parenthesized constructor, (float3(a,b,c)).x |
Supported | — |
Flat array initializers, const float4 s[5] = { …20 values… } |
Grouped into elements; an ambiguous layout fails the stage | Write one constructor per element |
= sampler_state { … } |
Accepted, but the fields are ignored | Choose filtering with a sampler name prefix |
Plain globals without initializer (float3 mus;) |
Become external inputs that read 0 | Initialize every global you read; on Windows its value was whatever the register held |
Float literals with many digits (1.00000011920928955078125, 4194304.0) |
Exact float32 | Some projectM builds round to 6 digits; avoid relying on the 7th |
Literals that overflow float (1e40) or a variable named inf/nan |
The stage fails | Don't |
Mixing int and float in some expressions |
GLSL ES can reject them (EVET - Spiracology 2) |
Write float literals (2.0, not 2) |
pow(x, y) with negative x, or zero x and y ≤ 0 |
Undefined in GLSL: any result, including NaN, on a given GPU | Guard: pow(max(x, 1e-6), y) or pow(abs(x), y) |
Large arguments to sin/cos in shader code |
Precision is driver-dependent | Wrap angles with frac or fmod first |
Undefined pow is the largest source of unpredictable output found by this project's source predictor. Twelve of 100 randomly chosen presets could not be forecast because of unresolved numeric domains; seven of them involve powers of negative or zero bases, the rest nonfinite warp coordinates, a division and a dot product.
Warp shader responsibilities¶
When the warp stage is a shader, MilkDrop ignores the per-frame decay variable and the shader decides the fade. MilkDrop's auto-generated default warp shader bakes in the file's decay value:
shader_body {
ret = tex2D(sampler_main, uv).xyz;
ret *= 0.98; // the file's fDecay; or try: ret -= 0.004;
}
projectM also exposes the decay to warp shaders through the vertex colour, but do not rely on that for MilkDrop compatibility: apply the fade yourself.
In MilkDrop 2, the preset's wrap setting only affects the fixed-function warp. A warp shader's sampler_main uses its name prefix, and without one it wraps and filters bilinearly (plugin.cpp sampler binding, milkdropfs.cpp:972). projectM, including ProjectM TV Engine, instead applies the preset's wrap setting to the unqualified sampler_main in the warp pass. To get the same result everywhere, name the mode explicitly: sampler_fw_main, sampler_fc_main, sampler_pw_main or sampler_pc_main.
Composite shader responsibilities¶
With a custom composite, the classic display effects do not apply: gamma, video echo, brighten, darken, solarize and invert are yours to implement (MilkDrop 2 behaves the same way). A typical composite:
shader_body {
float3 c = tex2D(sampler_main, uv).xyz;
c += GetBlur1(uv) * 0.4; // glow
c *= 1.0 + 0.3 * saturate(bass - 1.0); // pump with the bass
ret = pow(saturate(c), 0.92); // safe pow: base clamped to 0..1
}
Multiplying channels can crush to black
A composite that multiplies colour channels together, for example ret = c.x * c.x * c * GetMain(uv), turns a feedback buffer with little red into a black screen. One bundled preset investigated for "being black" did exactly this; it is not a bug. Test with both bright and dark feedback.
Resolution and texsize¶
texsize.xy is the canvas size, and stepping by texsize.zw moves exactly one texel. On a 1024×768 screen one texel was a visible step; at 3840×2160 it is 3.75× smaller in picture terms. When rendering larger, ProjectM TV reports a canvas with the area of its line reference size (about 1182×665 for a 16:9 render with the 1024×768 reference), so texel-stepping effects keep their authored size. Other projectM builds report the real render size. If you design texel steps for canvases of roughly 1024×768, they behave well everywhere. Details.