Skip to content
Elements · Fire & flamesIntermediate

Candle

A small flickering flame with a blue base and a warm halo, drawn as light over black.

UVTimeSurface
Open in Playground
  1. 01GoalElements
  2. 02TechniqueFire & flames
  3. 03ExecutionCandle
Full-screen quad

Parameters

0.60
0.25
2.5
1.20
#ff6b14
#ffd159
#1f40d9

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. fbm()Texture2Dfbm()fbm()fbm()
    UV
    Time

    01 Rising noise

  2. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    02 Teardrop mask

  3. clampClampclampclampclamp

    03 Heat

  4. mixMixmixmixmix

    04 Hot palette

  5. *Multiply***

    05 Halo and intensity

How it works

A candle flame is the simplest fire to look at, and that makes it a good one to build: one tongue, mostly still at the bottom, restless at the tip. The card makes it from a teardrop mask, two noises and a palette, and writes the result as emission: light, over black.

Step by step

  1. Rising noise. An fBm of four octaves reads a coordinate whose y subtracts time * speed, so the noise pattern travels upward through the flame like hot gas; the preview shows that value, around middle gray. Minus 0.5 it becomes the turbulence, centered on zero. A second, one-dimensional fBm of time alone gives the slow side-to-side sway of the whole flame.
  2. Teardrop mask. rise runs from 0 at the wick to 1 at the tip. The half-width of the flame at each height is width * sqrt(rise) * (1 - rise): the square root rounds the bottom, the 1 - rise closes the top to a point, and the widest part lands a third of the way up, where a candle flame bulges. Both the sway and the turbulence move the pixel’s x before it is compared with that width, and both are multiplied by the height (the sway by its square), so the base stays on the wick and the tip moves most. A smoothstep from half the width to the full width makes the edge soft.
  3. Heat. Inside the mask, heat is highest in the middle and near the bottom of the flame, and falls toward the edge and the tip. The turbulence is added in, so the bands of heat break into tongues instead of staying smooth ovals. Right at the wick the heat fades in over the first fifth of the height, since the wax vapor there hasn’t caught fire yet.
  4. Hot palette. hotPalette() turns heat into color with three chained mixes: from a dark version of Outer color to the full outer color, then to Inner color, then to near white. It is the order a hot object glows through, red, orange, yellow, white, and because one number drives it, the colors can never come out in the wrong order. Where the heat is low near the wick, the color is blended toward Base color: the faint blue at the bottom of a real candle flame, where the vapor burns with the most air and makes little soot.
  5. Halo and intensity. A soft glow, exp of the distance to the flame’s center, adds some of the outer color around the flame, the light it throws on the air. Everything is multiplied by Intensity.

Over black, then over a scene

The card’s render.json says "output": "emission": the result is light the surface gives off, not a base color. The preview draws it opaque over its own black, which is how this step of the learning order shows fire. Over a real scene the same shader goes on a quad with additive blending: black adds nothing, so the quad’s square edges vanish, and the halo brightens what is behind it. In Godot that is render_mode blend_add; on the canvas_item shader (the Godot guide shows it), and on the web gl.blendFunc(gl.ONE, gl.ONE). With Intensity above 1, an HDR pipeline with bloom and the clamp removed from the copied main(), the core glows past white.

Cost

Two fBm calls of four octaves are eight octaves of value noise, each four integer hashes and three mixes, about 95 operations, so about 760. The mask, the heat, the palette’s three smoothsteps and mixes, the sqrt, the exp and the length of the halo add about 90. About 850 operations per pixel, no texture reads, one pass. A candle covers a few hundred pixels, so this is nothing; a wall of fire covering the screen is a different matter, and that is where a flipbook or a scrolled noise texture wins.

Why it looks like fire

Three cues sell it, and each is a separate line of the shader. Motion goes up and grows with height (the turbulence scroll and the rise factor). The color follows heat in the right order (the palette). The flame adds light rather than covering (emission, then additive blending). Break any one of them, a sway that moves the base, a palette that goes yellow at the edges, a flame drawn with alpha over a bright background, and it stops reading as fire long before anyone notices the noise itself.

Make it in GLSL

The copied shader is a complete GLSL ES 3.00 fragment shader: the helpers, effect(), the uniforms and a main() that fills Inputs and writes the color encoded to sRGB.

  1. Draw a quad whose UV runs 0 to 1 from the bottom-left, passed as v_uv. The flame stands at the center, from 20% of the height up.
  2. Every frame, set u_time to seconds (performance.now() / 1000); u_resolution to the canvas size.
  3. Set the params once: the floats as they are, the three colors converted from sRGB to linear.

Over black, as on the card, draw it opaque. Over a scene, draw the quad after everything opaque, with depth writes off and additive blending:

gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.depthMask(false);

The shader writes alpha 1, so ONE, ONE adds its color as is. Black adds nothing, so the quad’s edges vanish and only the flame and its halo show. For a candle in 3D, turn the quad to face the camera every frame (a billboard), and give each candle a different time offset so they don’t flicker in sync.

Make it in Godot

The export draws the flame opaque over black, like the card. Over a scene, add blend_add to its render_mode line (render_mode unshaded, blend_add;). In VisualShader, the rising noise is a Texture2D node reading a NoiseTexture2D with UV plus Time, and the palette is a GradientTexture1D read by the heat.

The export is a canvas_item shader, for a flame drawn in 2D.

  1. Add a ColorRect (or a Sprite2D with any texture) where the candle is. The flame is shaped in UV space and stretches with the rect: at the preview’s proportions, 1.6 times wider than tall (for example 160 by 100 pixels), it looks as it does on the site. It stands in the middle, from 20% of the height up; on a narrower rect, raise Width.
  2. In its Material slot, create a New ShaderMaterial, then a New Shader, and paste the copied code.
  3. To draw it over the rest of the scene instead of over black, change the render mode line to render_mode unshaded, blend_add;. Godot then adds the flame’s color to what is behind, and the black corners of the rect disappear.

The shader flips Godot’s top-left UV, so the flame points up as on the site. For a candle in a 3D scene, the same body works in a spatial shader on a QuadMesh with billboard mode on and blend_add, unshaded in its render mode; the generated code here is the 2D one, so that is an adaptation by hand.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

candle.fragGLSL
#version 300 es// Fire & flames → Candle, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_height; // Height: 0.6 (0.2 to 0.8)uniform float p_width; // Width: 0.25 (0.05 to 0.6)uniform float p_speed; // Flicker speed: 2.5 (0.0 to 6.0)uniform float p_intensity; // Intensity: 1.2 (0.0 to 3.0)uniform vec3 p_outer; // Outer color: vec3(1.0, 0.42, 0.08), sRGB; upload it decoded to linearuniform vec3 p_inner; // Inner color: vec3(1.0, 0.82, 0.35), sRGB; upload it decoded to linearuniform vec3 p_baseColor; // Base color: vec3(0.12, 0.25, 0.85), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// Integer bit mixing instead of the one-line sine hash: it gives the same value on every GPU, and the rising// coordinates can grow for hours without the noise turning into stripes.uint scramble(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}float hashCell(ivec2 cell) {	return float(scramble(uint(cell.x) ^ scramble(uint(cell.y)))) / 4294967295.0;}float valueNoise(vec2 p) {	ivec2 cell = ivec2(floor(p));	vec2 f = fract(p);	vec2 u = f * f * (3.0 - 2.0 * f);	float a = hashCell(cell);	float b = hashCell(cell + ivec2(1, 0));	float c = hashCell(cell + ivec2(0, 1));	float d = hashCell(cell + ivec2(1, 1));	return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}// Four octaves are enough: the flame is small and the detail only has to read as flickerfloat fbm(vec2 p) {	float sum = 0.0;	float amplitude = 0.5;	for (int k = 0; k < 4; k++) {		sum += amplitude * valueNoise(p);		p = p * 2.0 + vec2(17.3, 9.1);		amplitude *= 0.5;	}	return sum;}// From cool to hot: dark red, the outer color, the inner color, then near white, each taking over a third of the rangevec3 hotPalette(float t) {	vec3 color = mix(p_outer * 0.15, p_outer, smoothstep(0.0, 0.35, t));	color = mix(color, p_inner, smoothstep(0.3, 0.7, t));	return mix(color, vec3(1.0, 0.97, 0.9), smoothstep(0.75, 1.0, t));}vec4 effect(Inputs i) {	// 0 at the wick, 1 at the tip	float rise = (i.uv.y - 0.2) / p_height;	vec2 scroll = vec2(i.uv.x * 6.0, i.uv.y * 4.0 - i.time * p_speed);	float rising = fbm(scroll);	float turbulence = rising - 0.5;	// The tip is light and moves a lot; the base is anchored to the wick, so the sway grows with the square of the height	float sway = (fbm(vec2(i.time * 0.7, 3.1)) - 0.5) * 0.12 * rise * rise;	float x = i.uv.x - 0.5 - sway - turbulence * 0.08 * rise;	// A teardrop: sqrt(rise) rounds the bottom and (1 - rise) closes the top to a point	float halfWidth = p_width * sqrt(max(rise, 0.0)) * max(1.0 - rise, 0.0);	float edge = abs(x) / max(halfWidth, 0.0001);	float flame = (1.0 - smoothstep(0.5, 1.0, edge)) * step(0.0, rise) * step(rise, 1.0);	// Hottest a little above the wick and in the middle, cooling toward the edge and the tip; the noise breaks the bands	// into tongues. Right at the wick the vapor hasn't caught yet, so the heat fades in over the first fifth.	float heat = flame * smoothstep(0.0, 0.2, rise) * clamp(1.1 - edge * 0.6 - rise * 0.8 + turbulence * 0.6, 0.0, 1.0);	vec3 fire = hotPalette(heat) * flame;	// Near the wick a real flame burns blue, where the vapor gets the most air	float base = flame * (1.0 - smoothstep(0.0, 0.15, rise)) * (1.0 - heat * heat);	fire = mix(fire, p_baseColor, base);	float halo = exp(-length(vec2(i.uv.x - 0.5, (i.uv.y - 0.2 - p_height * 0.4) * 0.6)) * 9.0) * 0.25;	vec3 glow = (fire + p_outer * halo) * p_intensity;	return vec4(glow, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
candle.gdshaderGodot shader
// Fire & flames → Candle, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float height : hint_range(0.2, 0.8, 0.01) = 0.6;uniform float width : hint_range(0.05, 0.6, 0.01) = 0.25;uniform float speed : hint_range(0.0, 6.0, 0.1) = 2.5;uniform float intensity : hint_range(0.0, 3.0, 0.05) = 1.2;uniform vec3 outer : source_color = vec3(1.0, 0.42, 0.08);uniform vec3 inner : source_color = vec3(1.0, 0.82, 0.35);uniform vec3 baseColor : source_color = vec3(0.12, 0.25, 0.85);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) {	return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// Integer bit mixing instead of the one-line sine hash: it gives the same value on every GPU, and the rising// coordinates can grow for hours without the noise turning into stripes.uint scramble(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}float hashCell(ivec2 cell) {	return float(scramble(uint(cell.x) ^ scramble(uint(cell.y)))) / 4294967295.0;}float valueNoise(vec2 p) {	ivec2 cell = ivec2(floor(p));	vec2 f = fract(p);	vec2 u = f * f * (3.0 - 2.0 * f);	float a = hashCell(cell);	float b = hashCell(cell + ivec2(1, 0));	float c = hashCell(cell + ivec2(0, 1));	float d = hashCell(cell + ivec2(1, 1));	return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}// Four octaves are enough: the flame is small and the detail only has to read as flickerfloat fbm(vec2 p) {	float sum = 0.0;	float amplitude = 0.5;	for (int k = 0; k < 4; k++) {		sum += amplitude * valueNoise(p);		p = p * 2.0 + vec2(17.3, 9.1);		amplitude *= 0.5;	}	return sum;}// From cool to hot: dark red, the outer color, the inner color, then near white, each taking over a third of the rangevec3 hotPalette(float t) {	vec3 color = mix(u_toLinear(outer) * 0.15, u_toLinear(outer), smoothstep(0.0, 0.35, t));	color = mix(color, u_toLinear(inner), smoothstep(0.3, 0.7, t));	return mix(color, vec3(1.0, 0.97, 0.9), smoothstep(0.75, 1.0, t));}vec4 effect(Inputs i) {	// 0 at the wick, 1 at the tip	float rise = (i.uv.y - 0.2) / height;	vec2 scroll = vec2(i.uv.x * 6.0, i.uv.y * 4.0 - i.time * speed);	float rising = fbm(scroll);	float turbulence = rising - 0.5;	// The tip is light and moves a lot; the base is anchored to the wick, so the sway grows with the square of the height	float sway = (fbm(vec2(i.time * 0.7, 3.1)) - 0.5) * 0.12 * rise * rise;	float x = i.uv.x - 0.5 - sway - turbulence * 0.08 * rise;	// A teardrop: sqrt(rise) rounds the bottom and (1 - rise) closes the top to a point	float halfWidth = width * sqrt(max(rise, 0.0)) * max(1.0 - rise, 0.0);	float edge = abs(x) / max(halfWidth, 0.0001);	float flame = (1.0 - smoothstep(0.5, 1.0, edge)) * step(0.0, rise) * step(rise, 1.0);	// Hottest a little above the wick and in the middle, cooling toward the edge and the tip; the noise breaks the bands	// into tongues. Right at the wick the vapor hasn't caught yet, so the heat fades in over the first fifth.	float heat = flame * smoothstep(0.0, 0.2, rise) * clamp(1.1 - edge * 0.6 - rise * 0.8 + turbulence * 0.6, 0.0, 1.0);	vec3 fire = hotPalette(heat) * flame;	// Near the wick a real flame burns blue, where the vapor gets the most air	float base = flame * (1.0 - smoothstep(0.0, 0.15, rise)) * (1.0 - heat * heat);	fire = mix(fire, u_toLinear(baseColor), base);	float halo = exp(-length(vec2(i.uv.x - 0.5, (i.uv.y - 0.2 - height * 0.4) * 0.6)) * 9.0) * 0.25;	vec3 glow = (fire + u_toLinear(outer) * halo) * intensity;	return vec4(glow, 1.0);}void fragment() {	Inputs i;	// UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = 1.0 / SCREEN_PIXEL_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading