This chapter is about shaders: small programs that run on the graphics card instead of the CPU, and decide the color of every pixel you see in a Unity game. Earlier chapters covered C#, C++, and data structures running on the CPU, one instruction after another. Shaders are a different world — thousands of copies of the same tiny program run at once, on different data, on a chip built only for this job. You will write real Unity ShaderLab/HLSL code, see exactly what it draws, and also look at Shader Graph, Unity's visual alternative to typing HLSL by hand.
A shader is a small program that runs on the GPU (Graphics Processing Unit — the chip built specifically to draw pixels fast, separate from the CPU that runs your C# scripts). A modern GPU has thousands of small processing cores. Instead of running one loop many times on a single fast CPU core, the GPU runs many copies of the same small program at the same time, each copy working on different data.
A 3D model (called a mesh) is built from triangles. Each triangle has 3 corners, called vertices (singular: vertex). To draw one mesh on screen, the GPU needs to answer two questions, over and over, for every triangle:
You write both of these as small functions. Unity compiles them into GPU code, uploads that code to the graphics card, and the GPU runs thousands of copies of each function in parallel. A mesh with 500 vertices does not run the vertex shader once and loop 500 times — the GPU (conceptually) runs 500 copies of it at once, each copy handling one vertex.
These two shader stages always run as a pair, and they have very different jobs and very different workloads.
The vertex shader's main job is transforming a position: taking a vertex position, which starts out relative to the 3D model itself (object space), and turning it into clip space (a coordinate system the GPU's rasterizer understands, used to decide what lands where on screen). It can also pass along extra data — UV coordinates, a normal vector, a vertex color — for the fragment shader to use later.
The fragment shader's main job is deciding a color. It receives the data the vertex shader passed along (already interpolated across the triangle — more on that in Section 5), and must output a final color for that one pixel.
Because a mesh usually has far fewer vertices than the number of pixels it covers on screen, the fragment shader usually runs vastly more times than the vertex shader. This is exactly why fragment shader code is usually the more performance-sensitive half of a shader — a few extra instructions there get multiplied by every pixel on screen, every frame.
Unity shaders are normally written as .shader files using ShaderLab (Unity's own wrapper language that organizes a shader's settings and properties), with the actual per-vertex/per-pixel math written in HLSL (High Level Shading Language — Microsoft's C-like language for GPU programs, also used directly by DirectX). ShaderLab is not HLSL; think of ShaderLab as the folder structure and settings around a block of real HLSL code sitting inside it.
A few pieces worth knowing by name before you see a full example:
Properties — declares the "knobs" a Material using this shader will show in the Inspector: colors, textures, numbers, sliders.SubShader / Pass — SubShader groups settings for one hardware tier; Pass is one actual draw of the geometry. Most simple shaders have exactly one SubShader with exactly one Pass.CGPROGRAM / ENDCG — marks the start and end of a block of real HLSL code, in Unity's classic Built-in Render Pipeline. This chapter uses this pair because it needs no extra package includes, which keeps the examples short.#pragma vertex vert / #pragma fragment frag — tells the compiler which function name is the vertex shader and which is the fragment shader.#include "UnityCG.cginc" — pulls in a file of ready-made helper functions and macros Unity provides, such as UnityObjectToClipPos.If your project uses URP or HDRP (Unity's newer Scriptable Render Pipelines), the block keywords change to HLSLPROGRAM / ENDHLSL, and the include path changes to files under Packages/com.unity.render-pipelines.universal/.... The HLSL concepts — vertex function, fragment function, semantics, uniforms — stay exactly the same. This chapter uses the classic CGPROGRAM/ENDCG style because it compiles in a plain Built-in Render Pipeline project with zero extra setup, which is the fastest way to see these ideas working for the first time.
Here is a complete, working shader. It draws every pixel of an object in a single flat color, which you can change from the Inspector.
Shader "Lesson/SolidColor"
{
Properties
{
_Color ("Color", Color) = (1, 0.4, 0, 1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
};
fixed4 _Color;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _Color;
}
ENDCG
}
}
}
What you see: create a Material, set its shader to Lesson/SolidColor (type the name into the shader dropdown's search box), drag it onto a Sphere or Cube in the Scene. The whole object turns a flat orange — the RGBA value (1, 0.4, 0, 1) from the Properties default. Change _Color in the Inspector and the object's color changes instantly, with no code changes and no recompiling.
Line by line: appdata ("application data") is the struct describing what one vertex looks like coming in — here, just a position, tagged with the semantic POSITION (a label telling Unity which mesh data channel to plug into this field). v2f ("vertex to fragment") is the struct the vertex function outputs and the fragment function receives; every v2f must include a field tagged SV_POSITION — the GPU's rasterizer requires this clip-space position to know where to draw. UnityObjectToClipPos is a helper function (from UnityCG.cginc) that does the actual math of moving a position from object space into clip space, using the object's transform, the camera, and the projection. frag must return something tagged SV_Target — the final color for this one pixel.
fixed4 _Color;) must match the property name in the Properties block exactly, underscore included. If the names do not match, Unity will not connect them — the shader still compiles, but the variable silently keeps its default value (usually all zeros, which reads as black and fully transparent), and nothing in the Inspector will actually change what you see.fixed4, half4, and float4 are all "4 numbers packed together" (used for colors as RGBA or positions as XYZW), but at different precision levels: fixed is lowest precision (fine for colors, cheapest on mobile GPUs), float is highest precision (needed for positions and math that must stay accurate), and half sits in between.Data placed into a v2f field inside vert() is not simply handed to the fragment shader as-is. The vertex shader runs once per corner of a triangle (3 times), but the fragment shader runs once per pixel inside that triangle (often thousands of times). For every one of those pixels, the GPU automatically blends the three corners' values together, weighted by how close that pixel is to each corner. This is called interpolation, and a v2f field that gets this treatment is called a varying (or an interpolator) — its value varies smoothly across the triangle's surface.
You can see this directly by passing a per-vertex color through to the fragment shader unchanged:
struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
};
struct v2f
{
float4 color : COLOR0;
float4 pos : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.color = v.color; // just pass it straight through
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return i.color; // this is already the INTERPOLATED color
}
What you see: if a mesh's three triangle corners are colored red, green, and blue (for example via a small script that writes to Mesh.colors), the triangle on screen shows a smooth rainbow blend across its surface, not three flat colored patches. The vert function only ever ran 3 times and only ever "saw" one color at a time — the smooth blending you see happened entirely inside the GPU's rasterizer, between the vertex stage and the fragment stage, and no HLSL code you wrote produced it directly.
A texture is a flat 2D image. To wrap that flat image onto a 3D mesh, every vertex additionally stores two numbers, called UV coordinates (u and v, each normally ranging from 0 to 1), that say "which point of the flat texture image sits at this corner of the mesh." These UVs are created when the mesh is UV-unwrapped in a 3D modeling tool, and Unity simply carries them along.
Reading a texture's color at a given UV is called sampling, done in HLSL with tex2D(textureVariable, uv). It does not just grab the nearest pixel of the image (an image pixel used this way is called a texel, short for "texture element") — by default it smoothly blends between nearby texels, called filtering, so the texture does not look blocky up close.
Shader "Lesson/TextureSample"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}
What you see: assign this shader to a Material, drag any texture (an image asset) into the _MainTex slot, and put the Material on a mesh. The texture wraps onto the mesh exactly as it was UV-unwrapped in the 3D tool — a character mesh shows its painted skin, a crate mesh shows wood planks on its sides.
sampler2D _MainTex; is the shader's handle to the texture asset itself. float4 _MainTex_ST; is a matching hidden property Unity fills in automatically from the Material's Tiling and Offset fields; TRANSFORM_TEX(v.uv, _MainTex) is a macro that applies that tiling/offset to the raw mesh UV before sampling, so changing Tiling/Offset in the Inspector works without any extra code.
A value like _Color or _MainTex is called a uniform in GPU terminology: it stays the exact same value for every single vertex and every single pixel during one draw call — "uniform" as in "not changing," the opposite of a varying (Section 5), which is different for every pixel. A Properties block is one way to give a uniform a default value editable in the Inspector, but you can also set uniforms directly from a C# script at runtime, using the Material's SetColor, SetFloat, SetVector, or SetTexture methods.
using UnityEngine;
public class SetShaderColor : MonoBehaviour
{
public Color myColor = Color.cyan;
Renderer rend;
void Start()
{
rend = GetComponent<Renderer>();
rend.material.SetColor("_Color", myColor);
}
}
What you see: attach this script to the same cube using the Lesson/SolidColor shader from Section 4, set myColor to cyan in the Inspector, then press Play. The cube switches from its default orange to cyan the instant Start() runs, because SetColor overwrites the _Color uniform for this object's material at runtime.
"_Color" passed to SetColor must match the HLSL variable name exactly, same as in Section 4 — get it wrong and nothing happens, with no error. Also watch rend.material (no "shared"): reading this property creates a brand-new, unique copy of the Material the very first time it is accessed on that Renderer. Doing this every frame, or on hundreds of objects that could have shared one Material, wastes memory and leaves behind extra Material instances Unity will warn you about. Use rend.sharedMaterial when you only need to read shared settings, and cache the result of rend.material in a field instead of calling it repeatedly.Unity automatically feeds a built-in uniform, float4 _Time, into every shader without you declaring it — no Properties entry needed. Its four components are different multiples of the time in seconds since the level loaded: _Time.y is the raw seconds value, and _Time.x, _Time.z, _Time.w are that same value divided or multiplied for convenience (so you rarely need to multiply it yourself). Because this value changes every frame with zero extra C# code, a shader can animate entirely on the GPU.
fixed4 _ColorA;
fixed4 _ColorB;
float _PulseSpeed;
fixed4 frag (v2f i) : SV_Target
{
float t = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5; // remap -1..1 to 0..1
return lerp(_ColorA, _ColorB, t);
}
What you see: add this to a shader's fragment function (with matching Properties entries for _ColorA, _ColorB, _PulseSpeed), press Play, and the object smoothly fades back and forth between the two colors forever, with no Update() method and no C# driving it frame to frame. sin(_Time.y * _PulseSpeed) oscillates between -1 and 1; multiplying by 0.5 and adding 0.5 remaps that into the 0..1 range lerp (linear interpolation, blending smoothly between two values) expects for its third argument.
mat.SetFloat("_PulseSpeed", 2f) changes how fast the pulse runs. The oscillation itself still costs zero CPU time per frame — the CPU only needs to set _PulseSpeed once, and the GPU keeps animating using its own always-updating _Time.A surface facing directly toward a light looks bright; a surface facing away looks dark. The simplest way to compute this, called diffuse or Lambertian lighting, compares two directions: the surface's normal vector (N — a vector pointing straight out of the surface, perpendicular to it) and the direction toward the light (L). The tool for comparing two directions is the dot product, written dot(N, L) in HLSL: when both vectors are normalized (scaled to length 1), the dot product equals the cosine of the angle between them — 1 when they point the same way (bright), 0 when they are perpendicular, and negative when facing away, which is clamped to 0 with saturate() so a surface never gets negative light.
Shader "Lesson/SimpleDiffuse"
{
Properties
{
_Color ("Tint", Color) = (1,1,1,1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float3 worldNormal : TEXCOORD0;
float4 pos : SV_POSITION;
};
fixed4 _Color;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = saturate(dot(N, L));
fixed3 litColor = _Color.rgb * NdotL;
return fixed4(litColor, 1);
}
ENDCG
}
}
}
What you see: put this shader on a sphere lit by Unity's default directional light. The side facing the light is fully colored; the side facing away fades toward black. Compared to the flat, single-color look from Section 4, the sphere now reads as a rounded 3D shape, purely because its brightness changes across its own surface.
UnityObjectToWorldNormal converts the mesh's normal from object space into world space, which matters because _WorldSpaceLightPos0 (a Unity built-in uniform giving the direction toward the scene's main directional light) is also in world space — comparing two vectors from different coordinate spaces with dot() would give a meaningless result. normalize() rescales a vector to length 1 without changing its direction; this is required before dot(), because the "cosine of the angle" shortcut only holds for normalized vectors.
normalize() on N or L before the dot product. Interpolation (Section 5) does not preserve length — a normal that was length 1 at every vertex can come out slightly shorter than 1 after being blended across a triangle. Skipping normalize() gives lighting that is subtly, unevenly too dark in places, a bug that is easy to miss just by eyeballing a screenshot._WorldSpaceLightPos0 only gives a correct, simple direction for a directional light (like a sun), which has no position, only a direction. A point light needs the light's actual world position so you can compute normalize(lightPos - worldPos) per-pixel instead — a detail left for a lighting-focused chapter, not needed to understand N dot L itself.GPUs are fast at running the same shader thousands of times because of how they schedule work, called SIMT (Single Instruction, Multiple Threads). The GPU does not give each pixel its own fully independent tiny processor. Instead it groups threads into fixed-size batches — commonly 32 on NVIDIA hardware, called a warp, or 64 on AMD hardware, called a wavefront — and every thread in that batch executes the exact same instruction at the exact same moment, just on its own data (its own UV, its own position, and so on). One instruction fetch serves all 32 (or 64) threads at once, which is what makes GPUs so much faster than CPUs for this kind of work.
This has a real cost when code branches. If an if/else in the fragment shader causes different threads in the same warp to want different paths, the GPU cannot actually run two different instructions simultaneously. Instead, it runs both branches, one after another, for the entire warp, and masks off (throws away) the results for threads that did not want that branch. This is called warp divergence.
fixed4 frag (v2f i) : SV_Target
{
fixed4 col;
if (i.uv.x < 0.5)
{
col = fixed4(1, 0, 0, 1); // red half
}
else
{
col = fixed4(0, 0, 1, 1); // blue half
}
return col;
}
What you see on screen: visually, this shader still correctly draws a hard-edged red/blue split down the middle of the object — the output is completely correct. The cost is invisible in a screenshot; it only shows up as extra GPU time when you profile, because of the masked-off work described above.
uv.x < 0.5 example above right at its edge.Shader Graph is Unity's node-based visual shader editor (available with URP or HDRP). Instead of typing HLSL text, you build the same vertex/fragment logic by dragging boxes called nodes onto a canvas and connecting their inputs and outputs with wires. Under the hood, Unity still generates real HLSL code from that graph and compiles it exactly the same way described in this whole chapter — Shader Graph is a visual front-end over the same GPU pipeline, not a different technology.
Every Shader Graph ends in a Master Stack, split into a Vertex block (position offsets, normal changes — matches the vert() function you have been writing) and a Fragment block (Base Color, Alpha, Smoothness, Emission — matches the frag() function). Data flows left to right: input nodes (Time, a Color property, a sampled Texture, UVs) feed through math nodes into the Master Stack's slots.
Here is the pulsing-color shader from Section 8, described as a Shader Graph node flow instead of HLSL text:
Read this the same way as the code in Section 8: a Time node feeds a Sine node (matching sin(_Time.y * ...)), then Multiply and Add nodes remap it from -1..1 to 0..1 (matching * 0.5 + 0.5), and a Lerp node blends between two exposed Color properties using that 0..1 value as its T input (matching lerp(_ColorA, _ColorB, t)). The result plugs into Base Color on the Master Stack, exactly where frag()'s return value would go in hand-written HLSL.
What you see: the exact same pulsing color animation as Section 8 — the graph and the hand-written shader compile down to equivalent GPU instructions and produce the same pixels on screen. Shader Graph also gives you a live preview on a sphere right inside the graph window as you connect nodes, updating instantly without needing to press Play.
.shader text file diffs cleanly in git, so two people editing different lines merge easily; a Shader Graph is a large serialized asset, and two people editing it at the same time usually causes a painful merge conflict.Studios working on real games commonly use both: artists prototype and tune a look in Shader Graph, while performance-critical or unusual shaders get hand-written HLSL from an engineer. Understanding the raw vertex/fragment/uniform/SIMT concepts in this chapter is useful either way, because it is exactly what Shader Graph is generating underneath — and it is what lets you read a profiler and understand why a particular node graph is expensive.
A shader can combine everything from this chapter in one file: texture sampling, a tint color uniform, N dot L lighting, and a time-based pulse.
Shader "Lesson/CombinedExample"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Tint ("Tint", Color) = (1,1,1,1)
_PulseSpeed ("Pulse Speed", Float) = 2
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float4 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Tint;
float _PulseSpeed;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 texColor = tex2D(_MainTex, i.uv);
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float NdotL = saturate(dot(N, L));
float pulse = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5;
fixed4 finalColor = texColor * _Tint * NdotL;
finalColor.rgb += pulse * 0.15; // a gentle glow pulse on top
return finalColor;
}
ENDCG
}
}
}
What you see: a textured, lit object — bright on the side facing the light, dark on the side facing away, tinted by _Tint — with a faint pulsing glow breathing across its whole surface over time. Every piece is exactly one of the earlier sections, just living in the same frag() function.
A short list of mistakes that trip up nearly everyone the first few times:
o.pos = UnityObjectToClipPos(...) in the vertex function — nothing draws, or it draws in a wildly wrong place, because the rasterizer never got a valid clip-space position.Properties entry whose name does not exactly match the HLSL variable name (Sections 4 and 7) — the shader still compiles, but the value silently stays at its default.normalize() before a dot() in a lighting calculation (Section 9) — lighting looks subtly wrong instead of failing loudly.float4 everywhere out of habit, even for simple colors, on a mobile target — wastes GPU bandwidth; fixed4/half4 are usually enough for colors (Section 4's tip).lerp()/step().CGPROGRAM/ENDCG (Built-in Render Pipeline) with HLSLPROGRAM/ENDHLSL (URP/HDRP) — pasting a shader written for one render pipeline into a project using the other usually fails to compile.POSITION or SV_Target on a struct field, telling the GPU what that field means or where it must go.tex2D()..shader file's properties, subshaders, and passes, surrounding the actual HLSL code.if/else, forcing the GPU to run both branches for the whole warp and mask off unwanted results.vert() and frag() functions.Lesson/Exercise1_Gradient, with two Color properties, _ColorBottom and _ColorTop. Using the mesh's UV coordinates, make the fragment shader blend between them based on i.uv.y, so an object looks like it has a smooth vertical gradient from bottom to top. Which UV value gives pure _ColorBottom? Which gives pure _ColorTop?
Shader "Lesson/Exercise1_Gradient"
{
Properties
{
_ColorBottom ("Bottom Color", Color) = (0,0,1,1)
_ColorTop ("Top Color", Color) = (1,1,0,1)
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
fixed4 _ColorBottom;
fixed4 _ColorTop;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return lerp(_ColorBottom, _ColorTop, i.uv.y);
}
ENDCG
}
}
}
i.uv.y == 0 gives pure _ColorBottom, and i.uv.y == 1 gives pure _ColorTop, because lerp(a, b, t) returns exactly a when t is 0 and exactly b when t is 1 (Section 6's diagram shows why v=0 is the bottom of a texture in Unity's convention). Every UV value in between blends smoothly, the same interpolation idea as Section 5, just computed by lerp() instead of the GPU's automatic vertex-to-fragment blending.
Float property named _ScrollSpeed. In the fragment shader, add _Time.y * _ScrollSpeed to the sampled UV's u coordinate before calling tex2D, so the texture appears to scroll sideways over time, like flowing lava or a conveyor belt.
Shader "Lesson/Exercise2_Scroll"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_ScrollSpeed ("Scroll Speed", Float) = 1
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _ScrollSpeed;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float2 scrolledUV = i.uv + float2(_Time.y * _ScrollSpeed, 0);
fixed4 col = tex2D(_MainTex, scrolledUV);
return col;
}
ENDCG
}
}
}
Doing the scroll math in the fragment shader is simplest here and works well, because a UV offset is only 2 numbers and cheap to compute per pixel. It could also be done in vert() instead, offsetting o.uv before it is even passed along — that runs fewer times (once per vertex instead of once per pixel, Section 2), so it is marginally cheaper, and for a uniform scroll like this one the visual result is identical either way. The important idea either way is the same one from Section 8: _Time.y lets the GPU animate on its own, frame after frame, with no C# script driving it.
if/else: if (i.uv.x < 0.5) col = red; else col = blue;. Rewrite it without any if/else, using step() and lerp() instead, so it produces the exact same red/blue split with no warp-divergent branch. (step(edge, x) returns 0 when x < edge, and 1 when x >= edge.)
fixed4 frag (v2f i) : SV_Target
{
float mask = step(0.5, i.uv.x); // 0 when uv.x < 0.5, 1 when uv.x >= 0.5
fixed4 col = lerp(fixed4(1,0,0,1), fixed4(0,0,1,1), mask);
return col;
}
Every thread in a warp now runs the exact same sequence of plain arithmetic instructions — compute step, compute lerp — no matter what its own uv.x is; only the numbers going into that arithmetic differ per thread. There is no point where different threads in the same warp need to execute different instructions, so there is no warp-divergence penalty from Section 10, even though the visual result on screen — a hard-edged red/blue split — is pixel-for-pixel identical to the branching version.