Files
2026-07-13 13:13:17 +08:00

14 KiB
Raw Permalink Blame History

Using Custom Shader Operators

Custom Shader Operators combine TiXL's preset system with its shader pipeline into a powerful and fun way to build your own effects. You write only the body of a shader function — the operator wraps it in a complete shader, compiles it on the fly, and shows errors directly in the editor.

As of v4.1 TiXL has:

  • [CustomPixelShader] — compute a color for every pixel of a texture
  • [CustomPointShader] — move, rotate, scale and color points in a point buffer
  • [CustomForce] — apply custom forces to particles
  • [CustomVertexShader] — displace and color the vertices of a mesh
  • [CustomFaceShader] — manipulate mesh triangles, including breaking them apart
  • [CustomSDF] — define a distance field that plugs into the field / raymarching pipeline

You don't need to write shader code to use them: open the Variations window, pick one of the presets and experiment with the parameters. Each op comes with a shared set of parameters — Offset, A, B, C, D, GainAndBias and a Gradient — that the shader fragment can read. Good presets include a comment explaining what each parameter does.

How it works

Each operator owns a template shader file (the TemplateFile parameter — open it to see exactly what wraps your code). The template declares the constant buffers, textures and helper functions, then pastes your ShaderCode into the body of its main function:

// ...template sets up uv, c, p, v etc. ...
{
//- METHOD -------------------------------------
/*{method}*/          // ← your ShaderCode lands here
//----------------------------------------------
}
// ...template writes the expected variables back...

Your fragment works with prepared local variables and is expected to set or modify specific output variables. Everything else — thread dispatch, buffer reads and writes — is handled by the template.

The AdditionalCode / AdditionalDefines parameter is inserted at global scope above the main function. Use it for your own helper functions, constants, or declarations of additional resources.

Required output variables

This is the contract per operator — what you get, and what you are expected to set:

Operator Runs per... Prepared variables You set / modify
[CustomPixelShader] pixel float2 uv, int2 PixelCoord, int2 TargetSize float4 c — the output color (defaults to white)
[CustomPointShader] point Point p, uint idx (alias i), float f = idx normalized to 0…1 p — written to the result buffer
[CustomForce] particle Particle p (read), float3 vel, float3 pos, float4 col, float age, float2 uv vel, col, pos — blended back with Amount
[CustomVertexShader] vertex PbrVertex v, uint vertexIndex v — written to the result mesh
[CustomFaceShader] triangle PbrVertex v1, v2, v3, int3 faceIndices, uint faceIndex, float3 pos1, pos2, pos3 v1, v2, v3 — written as an unshared triangle
[CustomSDF] field sample float3 p, float3 Offset, float A, B, C return the signed distance at p

The structs used above:

struct Point                  struct Particle               struct PbrVertex
{                             {                             {
    float3 Position;              float3 Position;              float3 Position;
    float FX1;                    float Radius;                 float3 Normal;
    float4 Rotation;  // quat     float4 Rotation;  // quat     float3 Tangent;
    float4 Color;                 float4 Color;                 float3 Bitangent;
    float3 Scale;                 float3 Velocity;              float2 TexCoord;
    float FX2;                    float BirthTime;              float2 TexCoord2;
};                            };                                float Selected;
                                                                float3 ColorRGB;
                                                            };

CustomPixelShader

Runs a pixel shader over the target texture. The inputs ImageA and ImageB are bound as Texture2D<float4> (with the aliases Image and Image2), the Gradient as a 1-pixel-high lookup texture. Three samplers are available: Sampler (wrapped), ClampedSampler, and CustomSampler (from the CustomSampler input).

The default code is a simple vignette:

float d = 1 - length(uv - 0.5 - Offset * float2(1,-1));
d = ApplyGainAndBias(d, GainAndBias);

c = ImageA.Sample(Sampler, uv);
c.rgb *= SampleGradient(d).rgb;

Displacing one image with another:

// Connect two images and use the Offset parameter
float4 cb = ImageB.Sample(Sampler, uv);
float d = Biased(cb.r);
c = ImageA.Sample(Sampler, uv - d * Offset * float2(1,-1));

Additional inputs: use [FloatsToBuffer] / [IntsToBuffer] for extra constant buffers, [SrvFromTexture2d] for more textures, and [SamplerState] for custom samplers. Declare each of them in AdditionalCode, e.g. Texture2D<float4> Image3 : register(t3);.

CustomPointShader

A compute shader running once per point of the connected buffer. p is a copy of the source point; whatever you leave in it is written to the output. f is the point's normalized position in the buffer (0…1) — ideal for distributing points along curves. Time holds the playback time scaled by TimeScale, and TotalCount the buffer size.

The default code moves points along their rotation and colors them with the gradient:

float t = Biased( frac(f * (B+1) + A) );
float3 pForward = qRotateVec3(float3(0,0,1), p.Rotation);

p.Position += pForward * (t - 0.5);
p.Position.y += sin(t * 2 * PI);
p.Color = SampleGradient(t);
p.Scale += t;

A grid of points forming a wave (from the Wave preset):

int gs = sqrt(TotalCount);
float2 gpos = float2(idx % gs, idx / gs) / gs - 0.5;

p.Position = float3(gpos.x, 0, gpos.y);
float h = length(sin(gpos * 8 + Offset.xz) * 0.1);
p.Position.y = h;
p.Color = SampleGradient(h * 8);

Generating points procedurally also works — set Count and leave the Points input empty; p then starts as a default point at the origin.

CustomForce

Runs once per particle of a particle system. You modify the local copies vel (velocity), col (color) and pos (position); after your code they are blended into the particle with the Amount parameter. Avoid writing pos directly when you can express the change as a velocity — positions bypass the simulation's speed handling.

age holds the particle's lifetime in seconds, and uv its current screen-space position (useful for sampling the Image input as a screen-aligned map). Note the sampler names here: ClampedSampler and WrappedSampler.

Pushing particles out of a connected SDF field:

float d = GetDistance(pos);
float3 n = GetNormal(pos);

if (d < 0)
    vel += n / (d + 1);

The full camera and object transform matrices (WorldToClipSpace, ObjectToWorld, …) are available for advanced effects.

CustomVertexShader

Runs once per vertex of the connected mesh. Modify the PbrVertex v — typically v.Position, v.Normal or v.ColorRGB.

Random displacement along the normals (the SimpleNoise preset):

float4 noise = hash41u(vertexIndex);
v.Position += v.Normal * (noise.xyz - 0.5) * Offset * A;

Coloring by a connected distance field:

// A: Falloff   B: Size
float d = GetField(float4(v.Position, 0)).w;
float t = Biased(smoothstep(1, 0, (d - A) / B));
v.ColorRGB = SampleGradient(t).rgb;

CustomFaceShader

Runs once per triangle and writes the three vertices back unshared — every face gets its own vertices. That allows faceting effects that would be impossible with shared vertices: shrinking, rotating or shifting whole triangles. pos1pos3 keep the original positions while you modify v1v3.

Coloring faces by their average height:

float3 avgPos = (v1.Position + v2.Position + v3.Position) / 3;
float4 color = SampleGradient((avgPos.y + Offset.y) * A + B);

v1.ColorRGB = color.rgb;
v2.ColorRGB = color.rgb;
v3.ColorRGB = color.rgb;

Exploding faces with per-face noise:

v1.Position += (hash41u(faceIndices.x).xyz - 0.5) * C;
v2.Position += (hash41u(faceIndices.y).xyz - 0.5) * C;
v3.Position += (hash41u(faceIndices.z).xyz - 0.5) * C;

CustomSDF

Unlike the operators above, [CustomSDF] does not run as its own shader — it defines a node in TiXL's shader graph. You write the body of a distance function and the field pipeline assembles it into whatever shader samples it (e.g. [RaymarchField], or the Field input of the other Custom ops):

float dCustom(float3 p, float3 Offset, float A, float B, float C)
{
    // ← your DistanceFunction code; must return the signed distance at p
}

The default is a simple sphere:

return length(p - Offset) - A;

Organic blobs (from the SinBlobs preset):

float scale = A;
float thickness = B;
float bias = C;

p *= scale;
return (abs(dot(sin(p*.5 + Offset), cos(p.zxy * 1.23)) - bias) / scale - thickness) * 0.55;

A GLSL-style mod(x, y) macro is predefined, which makes porting fractal one-liners from Shadertoy or jbaker.graphics straightforward — many of the shipped presets (BoxFold, SpiderCave, PipeMaze, …) come from there. Put helper functions into AdditionalDefines. There is no Gradient or Image here — a distance function only returns geometry; color it with the field operators that consume it.

Visualize the result with [VisualizeFieldDistance], render it with [RaymarchField], and modify it with [BendField], [TransformField], [PolarRepeat] and friends.

Using Fields

[CustomPointShader], [CustomForce], [CustomVertexShader] and [CustomFaceShader] have a Field input that accepts field operators — anything from the field namespace, including your own [CustomSDF]. The template exposes them through:

Function Description
GetField(float4 p) Samples the connected field at p.xyz; returns rgb = color, w = distance
GetDistance(float3 p) Shorthand for the distance value
GetFieldNormal(float3 p) Normalized field gradient (in [CustomForce] this is called GetNormal)

This makes it easy to color, displace or collide against any SDF you can build in the graph.

Built-in helper functions

Available in all template-based operators (everything except [CustomSDF], which only sees what its shader-graph context and AdditionalDefines provide):

Name Description
Biased(float f) Applies the GainAndBias parameter remapping to f
SampleGradient(float f) Samples the Gradient parameter at f (0…1)
ApplyGainAndBias(f, gainBias) Like Biased but with an explicit gain/bias float2
hash11(f)hash44(v) Fast hash functions: digits = output/input component count, e.g. hash41u(uint) returns a float4 from a uint seed

Noise functions (cnoise, snoise, curlNoise, …) are included in [CustomVertexShader] and [CustomFaceShader]. For the other ops you can include them via AdditionalCode.

Using Quaternions

Point rotations are stored as quaternions. These helpers are available in all ops except [CustomPixelShader]:

Name Description Parameters
qMul Performs standard Hamiltonian product of two quaternions. float4 q1, float4 q2
qRotateVec3 Rotates a 3D vector by a quaternion using an optimized formula. float3 v, float4 q
qConjugate Returns the conjugate of a quaternion (negates the imaginary part). float4 q
qInverse Calculates the inverse of a quaternion. float4 q
qFromAngleAxis Creates a quaternion representing a rotation of angle around axis. float angle, float3 axis
qFromVectors Creates a quaternion representing the rotation from v1 to v2. float3 v1, float3 v2
qLookAt Creates a rotation quaternion that points toward forward with specialized up. float3 forward, float3 up
qSlerp Performs Spherical Linear Interpolation between two quaternions. float4 a, float4 b, float t
qFromEuler Converts Euler angles (yaw, pitch, roll) to a quaternion. float yaw, float pitch, float roll
qToMatrix Converts a quaternion into a 4\times4 rotation matrix. float4 quat
qFromMatrix3 Simplified conversion from a 3\times3 matrix to a quaternion. float3x3 m
qFromMatrix3Precise Robust conversion from a 3\times3 matrix to a quaternion (handles all cases). float3x3 m

Example — orienting points along a generated curve (from the Knot preset, with the Generator function defined in AdditionalDefines):

p.Position = Generator(f);
float3 up = float3(0,-1,0);
float3 fwd = normalize(Generator(f - .01) - Generator(f + .01));
p.Rotation = qLookAt(fwd, up);

Tips

  • Open the template. The TemplateFile parameter points to the full shader source — it is the authoritative reference for what surrounds your code and which registers are bound.
  • Use the presets as starting points. They are regular parameter snapshots; saving your own preset captures the shader code along with all parameters.
  • Comment your parameters. A line like // A: Falloff at the top of the fragment makes presets reusable for others.
  • Compile errors appear on the operator — click it to read the HLSL compiler message; line numbers refer to the assembled shader, so check the template for context.

See also