A technical artist (often shortened to "TA") sits between the art team and the programming team. Artists model, texture, and animate; programmers write gameplay code and engine systems. The technical artist's job is to build the look of the game — water, fire, magic, dissolving enemies, holograms, glowing rims — without writing a full rendering engine from scratch.
The tool that makes this possible is a node graph: a visual editor where you connect small boxes (called nodes) with wires instead of typing lines of code. In Unity the built-in tool is called Shader Graph. A popular third-party alternative is Amplify Shader Editor. Both work the same way: you wire nodes together, and the tool generates real GPU code behind the scenes.
That generated code is written in HLSL (High-Level Shading Language) — the language GPUs actually run. You do not need to write HLSL by hand to make most of the effects in this lesson. But knowing roughly what HLSL a node produces makes you a much stronger technical artist, because you can read other people's shaders, debug weird visual bugs, and drop into a Custom Function node when a graph alone cannot do what you need. That is why every recipe in this lesson shows you both: the node graph AND the plain HLSL it is equivalent to.
Nothing here is magic. A node graph is a drawing tool for writing code. Every node is a small piece of math or a texture lookup, and every wire is a value flowing from one calculation into the next. Once you can read a graph as "math flowing left to right," shaders stop being scary.
float, float2, float3, float4, functions, if, loops, and math operators. The main difference is that a shader runs once per vertex or once per pixel, thousands of times in parallel, instead of once from a single main().Every node graph, no matter the tool, is built from the same three ingredients:
Float (one number), Vector2 (two numbers, e.g. a UV), Vector3 (three numbers, e.g. a direction or an RGB color), Vector4/Color (four numbers, e.g. RGBA).Every graph ends at one special node: the Master node (split into a Fragment and a Vertex stack in newer Shader Graph, or a single output node in Amplify). This is where you answer the questions the renderer asks about each pixel: "what color is this," "which way does its surface normal point," "how metallic and how smooth is it," "does it glow," "is it see-through."
That tiny graph above is a complete, working shader. It says: "look up a pixel color from this texture, using these UVs, and use that as the Base Color." Nothing else is plugged in, so the engine uses sensible defaults for Metallic, Smoothness, and the rest. This is the same idea as a C# method that only fills in the parameters it cares about and leaves the others at their default values.
Here is the HLSL a graph like this actually compiles down to, simplified:
// Simplified fragment shader — roughly what the graph above generates
Texture2D _MainTex;
SamplerState sampler_MainTex;
float4 Frag(float2 uv : TEXCOORD0) : SV_Target
{
float4 baseColor = _MainTex.Sample(sampler_MainTex, uv);
return baseColor; // becomes the Base Color the renderer lights and shades
}
Every node you drag into Shader Graph turns into a line or two like this. Learning shaders through node graphs and learning shaders through HLSL are the same skill — the graph just draws the data flow for you.
A texture is just an image stored in memory — a grid of pixels, each with a color (and often extra channels like transparency). To put that image onto a 3D mesh, every vertex of the mesh stores an extra pair of numbers called UV coordinates (the letters U and V are used instead of X and Y so they are not confused with 3D position). UV values normally range from 0 to 1, no matter how big the actual texture is in pixels.
The node (or HLSL function) that reads a color out of a texture at a given UV is called sampling. In Shader Graph it is the Sample Texture 2D node: plug in a Texture2D and a UV (Vector2), and it outputs an RGBA color, plus separate R, G, B, A outputs for when you only need one channel.
// HLSL equivalent of "Sample Texture 2D"
Texture2D _Tex;
SamplerState sampler_Tex; // the sampler decides filtering (blurry vs blocky) and wrap mode
float4 SampleMyTexture(float2 uv)
{
return _Tex.Sample(sampler_Tex, uv);
}
Two sampler settings matter a lot for art: Filter (Point = blocky/pixelated, Bilinear/Trilinear = smooth) and Wrap Mode (Repeat = the texture tiles forever past 0 and 1, Clamp = it stretches its edge pixels forever, useful for UI or decals).
(0.5, 0.5) are each reading the middle of their own texture space — moving an object in the world does not change its UVs. A texture that should react to world position instead (like a puddle that stays put while a character walks over it) needs a different technique, sampling by world position, which is outside this lesson.Once you can sample a texture, the next skill is bending the UVs themselves before the sample happens. Three moves cover almost everything an artist needs:
(4, 4) makes the texture repeat 4 times along U and 4 times along V.// HLSL: tiling + offset + panning combined
float2 PanUV(float2 uv, float2 tiling, float2 offset, float2 speed, float time)
{
float2 tiled = uv * tiling + offset; // Shader Graph's "Tiling And Offset" node
float2 panned = tiled + speed * time; // scroll over time
return panned;
}
Picture a water texture sampled with UV between (0,0) and (1,1). With speed = (0.1, 0), after 10 seconds the sample point has drifted by 1.0 in U — a full texture width — so the pattern has scrolled once and quietly wrapped back around. This only looks seamless if Wrap Mode is set to Repeat, and the source texture was painted so its edges line up (a "tileable" texture).
Lerp stands for linear interpolation. It takes two values A and B and a blend factor T that runs from 0 to 1, and returns a value that slides smoothly from A (at T=0) to B (at T=1).
// Lerp works on floats, colors, and vectors — same formula every time
float Lerp(float a, float b, float t)
{
return a + (b - a) * t;
}
// t = 0 returns a
// t = 0.5 returns exactly halfway between a and b
// t = 1 returns b
In Shader Graph, the Lerp node takes A, B, T and outputs the blended value. T almost never comes from nowhere — it usually comes from another texture (a mask), from a Fresnel node, from noise, or from a slider the artist exposes as a material property. Every recipe later in this lesson uses Lerp at least once, because "blend between two things using a mask" is the single most common operation in shader art.
Lerp blends smoothly across the whole 0..1 range. Sometimes you want a hard cutoff instead — a clean line where one side is fully one thing and the other side is fully something else. That is what Step does.
// Step: a hard on/off switch
float Step(float edge, float x)
{
return (x < edge) ? 0.0 : 1.0;
}
Step(0.5, x) returns 0 for every x below 0.5, and 1 for every x at or above 0.5. Feed that result into a Lerp's T, or use it directly as a mask, and you get a razor-sharp edge instead of a gradient.
Smoothstep is Step's softer cousin: instead of one edge value, it takes two — edge0 and edge1 — and eases smoothly between them instead of snapping.
// Smoothstep: an eased ramp between edge0 and edge1
float Smoothstep(float edge0, float edge1, float x)
{
float t = saturate((x - edge0) / (edge1 - edge0)); // saturate = clamp to 0..1
return t * t * (3.0 - 2.0 * t); // the "ease" curve — flat at both ends
}
Where these show up: a dissolve edge needs a thin band that glows right at the cutoff line — you get that band by combining two smoothsteps, or by using Smoothstep with a very narrow edge0/edge1 gap. A cel-shaded (toon) light band uses Step or Smoothstep on the lighting value instead of a smooth gradient, to get flat color bands. A water shoreline foam line uses Step on "distance from the shore" to decide where foam starts.
A lot of shader inputs do not naturally come in the range you need. A noise texture samples out values from 0 to 1, but you might need -1 to 1 to push a normal map both left and right. A mask might only ever reach 0.2 to 0.8 in practice, and you want to stretch it to use the full 0..1 range for more contrast. Remap does exactly this: it takes a value inside an input range and rescales it into an output range, keeping its relative position.
// Remap: rescale x from [inMin, inMax] into [outMin, outMax]
float Remap(float x, float inMin, float inMax, float outMin, float outMax)
{
float t = (x - inMin) / (inMax - inMin); // where x sits inside the input range, as 0..1
return outMin + t * (outMax - outMin); // place that same relative position in the output range
}
// Example: Remap(0.75, 0.0, 1.0, -1.0, 1.0)
// t = (0.75 - 0) / (1 - 0) = 0.75
// result = -1 + 0.75 * (1 - (-1)) = -1 + 1.5 = 0.5
In Shader Graph this is the Remap node, with inputs In, In Min Max, and Out Min Max. You will reach for it constantly: brightening a dim mask, converting a 0..1 noise into a -1..1 displacement, or squeezing a Fresnel result into a narrower band so a rim glow looks tighter.
Two more building blocks finish the toolkit. The Time node outputs the number of seconds since the scene started (Shader Graph also gives you pre-scaled versions for slow or fast effects). Multiply Time by a speed and feed it anywhere a number is expected — UV offset, a rotation angle, a color's brightness — and that value now changes every frame, which is what makes an effect feel alive instead of a still image.
Noise nodes (Shader Graph ships a Simplex Noise node; Perlin Noise is the classic equivalent) generate a grayscale pattern of smooth, organic blobs from a UV input — no texture asset needed. Unlike pure random static, noise is continuous: nearby UV positions produce similar values, so it looks like natural variation (clouds, dissolve patterns, skin pores) instead of TV snow.
Noise is the secret ingredient behind almost every "magical" or "organic" effect: dissolve patterns, fire flicker, hologram glitches, water surface distortion, cloud shapes. Combined with Time (scrolling the UV fed into the noise node) you get noise that animates — churning fire, drifting fog, a pulsing forcefield.
Water and lava both use the same trick: take a tileable texture (waves or cracked magma) and a matching normal map (a texture that stores fake surface bumps so lighting reacts as if the surface were wavy, even though the mesh is flat), then pan both across the surface over time. Panning two normal-map samples at different speeds and directions, then blending them, breaks up the obviously-repeating pattern and reads as constantly shifting liquid.
float2 uvA = uv * tiling + _Time.y * speedA; // _Time.y is seconds in Unity's built-in shaders
float2 uvB = uv * tiling + _Time.y * speedB * float2(0.7, -1.0); // different direction and speed
float3 normalA = UnpackNormal(_NormalTex.Sample(sampler_NormalTex, uvA));
float3 normalB = UnpackNormal(_NormalTex.Sample(sampler_NormalTex, uvB));
float3 blendedNormal = normalize(normalA + normalB); // simple, cheap normal blend
float3 albedo = _AlbedoTex.Sample(sampler_AlbedoTex, uvA).rgb;
A flat plane reads as a gently rolling water surface: light glints shift and swim across it instead of standing still, even though no vertex ever moves. For lava, swap the blue-ish water normal/albedo pair for an orange, cracked-rock pair, and add a strong Emission from a separate, slower-panned "glowing cracks" texture.
The mesh is completely static — only the UVs fed into the texture samples change every frame. That is the whole trick: motion made entirely out of "what pixel do I look up," at almost no performance cost, because the GPU was already sampling a texture anyway.
A "dissolve" makes an object crumble away along a noisy, organic edge instead of just fading out or popping — think of a Marvel-style disintegration, or an enemy burning away. The trick: sample a noise texture across the surface, compare it against a Dissolve Amount slider (0 = fully visible, 1 = fully gone) using Step, and cut away (clip) any pixel whose noise value is below that threshold. A thin Smoothstep band right at the cutoff is colored bright and pushed into Emission to fake a burning edge.
float noiseValue = SimplexNoise(uv * noiseTiling);
// cut the pixel away entirely once noise falls below the dissolve amount
clip(noiseValue - dissolveAmount); // clip(x): if x < 0, discard this pixel, draw nothing
// thin glowing band exactly at the cutoff line
float edgeWidth = 0.05;
float band = smoothstep(dissolveAmount, dissolveAmount + edgeWidth, noiseValue)
- smoothstep(dissolveAmount + edgeWidth, dissolveAmount + edgeWidth * 2.0, noiseValue);
float3 emission = edgeColor * band * edgeBrightness;
clip() is an HLSL instruction that throws away a pixel completely (it never gets written to the screen or the depth buffer) if the value passed in is negative. By comparing noise to a slider you drive from code (for example, animating dissolveAmount from 0 to 1 over 2 seconds when an enemy dies), the cutoff line creeps across the noise pattern, and because the noise is organic-looking, the edge looks like burning or crumbling instead of a straight wipe.
Alpha Clip Threshold on Shader Graph's Master node does the clip() call for you — wire noiseValue - dissolveAmount into it (or use the dedicated Clip node) and enable Alpha Clipping in the graph settings.Look at a glass bottle or a soap bubble: the edges, where you are looking almost along the surface instead of straight at it, look brighter and more reflective than the center. That real-world phenomenon is called the Fresnel effect (pronounced "freh-NEL"), and it is the basis of almost every glowing-rim or energy-shield effect in games. It compares the direction the camera is looking at a point against that point's surface normal (the direction the surface faces): dead-on = 0, grazing edge = 1.
float3 viewDir = normalize(_WorldSpaceCameraPos - worldPos);
float fresnel = 1.0 - saturate(dot(normalize(worldNormal), viewDir));
fresnel = pow(fresnel, rimPower); // higher power = thinner, tighter rim
float3 rimEmission = rimColor.rgb * fresnel * rimIntensity;
dot(normal, viewDir) (the dot product, a way to compare two directions: 1 when they point the same way, 0 when perpendicular) is largest when you are looking straight at a surface, and shrinks toward 0 at the silhouette edge. Flipping it with 1 - x makes the edges bright instead of the center, and pow() lets you squeeze that bright zone into a thin line (high power) or a broad glow (low power, close to 1). Because it only touches Emission, this recipe is cheap and stacks on top of any other material — you almost never build a shader that is only fresnel; you add fresnel to an existing material.
A hologram look combines three things you already know: a Fresnel rim (so edges glow more than flat faces, like a projection), horizontal scanlines (thin repeating stripes made from UV plus a sine wave), and a flicker (the whole thing's brightness wobbling over time, using a sine wave or noise), all pushed into Emission on top of a base color, usually with the material set to transparent.
float fresnel = pow(1.0 - saturate(dot(normalWS, viewDir)), rimPower);
float scanline = sin(uv.y * scanlineFrequency + _Time.y * scrollSpeed);
scanline = scanline * 0.5 + 0.5; // remap -1..1 to 0..1
scanline = smoothstep(0.4, 0.6, scanline); // sharpen into thin bright lines
float flicker = sin(_Time.y * flickerSpeed) * 0.5 + 0.5;
flicker = lerp(0.6, 1.0, flicker); // never fully off, just dimmer
float3 emission = hologramColor.rgb * (fresnel + scanline) * flicker;
None of the three ingredients are new — this recipe is proof that a handful of building blocks (Fresnel, a sine wave driven by UV, a sine wave driven by Time) recombine into a completely different-looking effect just by changing what feeds what. Scanlines are a sine wave of UV.y sharpened with Smoothstep into stripes; flicker is a sine wave of Time remapped so it dims but never goes fully black. This is the normal way technical artists work: not inventing new math, but recombining the same eight or nine nodes in a new order.
sin() flicker for a Noise node sampled with only Time as input (no UV) for a less regular, more "glitchy" flicker — a regular sine wave reads as mechanical, noise reads as unstable.The idea: games often need the same mesh in different team colors, faction colors, or material variants (a character's cloth is one color, its leather straps another) without authoring separate textures for every combination. The trick is a grayscale mask texture painted once (white = "recolor this," black = "leave this alone"), used as the T of a Lerp between the original albedo and a tint color exposed as a material property.
float3 albedo = _AlbedoTex.Sample(sampler_AlbedoTex, uv).rgb;
float mask = _MaskTex.Sample(sampler_MaskTex, uv).r; // 0..1, painted by an artist
float3 finalColor = lerp(albedo, tintColor.rgb, mask);
What it looks like: a character's cloth shifts to whatever team color a match assigns, while its skin, metal buckles, and leather (all painted black in the mask) never change. Explanation: this is Lerp from section 5, with the mask texture supplying a different T per pixel instead of a single value — the mask is really just a saved, hand-painted map of blend factors.
The idea: everything so far only changed pixel colors (the fragment/pixel stage). Shaders can also move geometry, in the vertex stage, before rasterization even happens. For grass blowing in the wind, you do not want the whole blade to slide sideways (it would look like it is floating) — only the tip should sway, while the base stays planted. That per-vertex "how much can this point move" amount is painted into the mesh's vertex color when the grass asset is made (white at the tip, black at the base), and the shader reads it back as a mask.
// Vertex shader -- runs once per vertex, not per pixel
float windPhase = _Time.y * windSpeed + worldPos.x * 0.3; // offset by position so blades don't all sway in sync
float sway = sin(windPhase) * windStrength;
float tipAmount = vertexColor.r; // 0 at the base, 1 at the tip -- painted into the mesh
float3 displaced = vertexPosition + float3(sway, 0, 0) * tipAmount;
Explanation: vertexColor.r is a value painted directly onto the mesh's vertices (most 3D tools let an artist paint vertex colors like a coarse, per-vertex texture). Multiplying the sway by that mask means the base (r=0) is multiplied by zero and never moves, while the tip (r=1) gets the full sway. Adding worldPos.x into the sine's input means neighboring blades sit at slightly different points in the wave, so a whole field sways like a rolling wave instead of every blade snapping left and right in perfect unison.
(u, v) address, each usually 0..1, stored per vertex, used to look up a texture.Tiling = (2, 2), Offset = (0, 0), and pans it with Speed = (0.1, 0.05). The mesh's raw UV at a point on the surface is (0.3, 0.6). What UV is actually sampled at Time = 4.0 seconds? Use finalUV = uv * tiling + offset + speed * time.tiled = uv * tiling = (0.3 * 2, 0.6 * 2) = (0.6, 1.2)
tiled+off = tiled + offset = (0.6, 1.2) + (0, 0) = (0.6, 1.2)
panAmount = speed * time = (0.1*4, 0.05*4) = (0.4, 0.2)
finalUV = tiled+off + panAmount = (1.0, 1.4)
The sampled UV is (1.0, 1.4). A value above 1 means nothing special by itself — it is the Wrap Mode that decides what happens next. With Repeat wrap mode (the normal choice for a scrolling water or lava tile), the GPU quietly wraps 1.0 back to 0.0 and 1.4 back to 0.4, so it actually samples the same pixel as UV (0.0, 0.4) — the texture has scrolled past a full tile and wrapped seamlessly. With Clamp instead, sampling would incorrectly freeze on the texture's edge pixel, which is why panning textures should always use Repeat.
dissolveAmount = 0.4 and an edge width of 0.05. At one pixel, the noise texture returns 0.42. (a) Does clip(noiseValue - dissolveAmount) keep or discard this pixel? (b) What is the value of smoothstep(0.4, 0.45, 0.42), rounded to two decimal places, and what does it represent?(a) clip(noiseValue - dissolveAmount) = clip(0.42 - 0.4) = clip(0.02)
0.02 is NOT less than 0, so the pixel is KEPT (still visible)
(b) smoothstep(edge0=0.4, edge1=0.45, x=0.42)
t = (x - edge0) / (edge1 - edge0) = (0.42 - 0.4) / (0.45 - 0.4) = 0.02 / 0.05 = 0.4
result = t*t*(3 - 2*t) = 0.4*0.4*(3 - 0.8) = 0.16 * 2.2 = 0.352
rounded: 0.35
(a) The pixel survives — it is just barely past the dissolve threshold, so it has not been eaten away yet. (b) 0.35 is how far this pixel sits inside the thin glowing edge band, on a curved (eased) scale rather than a straight line. It is used to multiply the edge color for the burning-edge Emission, so a pixel right at the very start of the band glows dimly and one further into the band glows closer to full brightness.
One reasonable graph (this is a design exercise, so other layouts can be just as correct):
Reading it left to right: Fresnel gives the rim brightness. A second branch pans UV over time and samples a hexagon-pattern texture, so the hex pattern slowly drifts across the surface. Those two are added together (rim plus hex pattern) — using Add here instead of Lerp because both should show up at once, not blend between each other. A third branch drives a sine wave from Time, remapped so brightness never drops below half, and multiplies the combined result — this is the same "flicker" idea from the hologram recipe, just applied to the whole effect instead of only Emission's base value. Finally everything is tinted by a single exposed ForcefieldColor property and fed into Emission, so a designer can reuse one shader for a red enemy shield and a blue player shield just by changing that one color.