Burning edge
A surface eaten by growing holes, each ringed by a charred band and a thin glowing ember.
- 01GoalReveal
- 02TechniqueDissolve
- 03ExecutionBurning edge
Parameters
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.
tiledNoiseTexture2DtiledNoisetiledNoisetiledNoise UVTime01 Noise
mixMixmixmixmix In02 Threshold
stepStepstepstepstep In03 Cut
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In04 Char band
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In05 Ember band
mixMixmixmixmix In06 Color
How it works
The card burns holes into a sphere. Drag Burnt and the holes grow from a few specks to the whole surface; each one is ringed by a dark charred band and, at the very edge, a thin line of ember. Everything comes from one noise value per pixel and three thresholds on it.
1. Noise
Noise A smooth pseudo-random function of position. Random values or gradients sit on a grid and are blended in between, so nearby points get similar values and the result has no visible pattern. Hash A function that scrambles its input into a value that looks random but is always the same for the same input. Shaders use it for per-pixel and per-cell randomness. UV coordinates A 2D position on a surface or the screen, usually from 0 to 1 on both axes. Shaders draw shapes and patterns as functions of it.tiledNoise is the value noise from the noiseA smooth pseudo-random function of position. Random values or gradients sit on a grid and are blended in between, so nearby points get similar values and the result has no visible pattern.h = fract(sin(dot(p, k)) × big) at the
four corners of each grid cell, blended with the smoothstep curve. It runs on the mesh’s UVA 2D position on a surface or the screen, usually from 0 to 1 on both axes. Shaders draw shapes and patterns as functions of it.uv = gl_FragCoord.xy / resolutionhashCell, not
the one-line sine hash: it gives the same bits on every GPU, so the holes open in the same places on the web and in
Godot. Its integer mixing is most of the card’s math, about 90 of its 125 operations.
One change makes it fit a sphere. The sphere’s u coordinate goes once around it and jumps from 1 back to 0 on one
side, and plain noise would jump with it, drawing a seam from pole to pole. Here every cell index goes through
cell % period before it is hashed, so the last column of cells reuses the first column’s corners and the noise
wraps around without a break. The period is twice as many cells around as from pole to pole because the sphere is
twice as far around as from top to bottom, which keeps the blotches round instead of stretched. Blotch count sets
the number of cells.
2. Threshold
Burnt goes from 0 to 1, but the threshold the noise is compared with runs from a little below 0 up to 1. At 0 the
whole edge band would otherwise already sit on the darkest noise values and show as dark specks; starting below 0
keeps the untouched surface clean. At 1 the threshold is above every value the noise can take, so nothing is left.
mix(-3.0 * p_width, 1.0, p_amount) is that remap in one call.
3. Cut
Alpha clip Discarding every fragment whose alpha is below a threshold and drawing the rest fully opaque. The surface stays in the opaque pass, writes depth and needs no sorting, but its edges are hard.step(n, threshold) is 1 where the noise is at or below the threshold: the pixels that are gone. The shader returns
1.0 - burnt as alpha, and the card’s pass has an alpha clip at 0.5, so those pixels are discarded rather than drawn
transparent. Alpha clipDiscarding every fragment whose alpha is below a threshold and drawing the rest fully opaque. The surface stays in the opaque pass, writes depth and needs no sorting, but its edges are hard.if (alpha < threshold) discard; keeps the sphere in the opaque pass: it writes depth, sorts like
any solid object and casts a proper shadow in an engine. The price is a hard, pixel-sharp edge, which the ember band
hides. Back faces stay culled, so the holes look straight through the sphere to whatever is behind it; a thin, open
mesh such as a letter or a flag turns culling off instead, so its back side burns too.
4. Char band
The pixels just above the threshold are the next to go. smoothstep(threshold, threshold + 3.0 * p_width, n) rises
from 0 at the hole’s edge to 1 a little further in, so one minus it is a band that is 1 at the edge and fades into the
surface. Mixing the surface color toward almost black with it gives the scorched halo around every hole.
5. Ember band
The same smoothstepA soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between. Smoothstep A soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)
6. Color
The last mix paints the ember color over the charred surface along that thin band. The preview is unlit, so the
ember already reads as glowing; in an engine, sending the band to emission as well lets bloom catch it.
Every band follows the noise, so they all move together as the threshold rises, and the shape of the holes alone decides where the fire is.
Make it in GLSL
The copy button gives a complete WebGL2 fragment shader: the effect, the Inputs it reads and a main() that
discards the burnt pixels. Pair it with a vertex shader that writes the mesh’s UV to v_uv. It must still declare and
write v_position, v_normal and v_view, which main() reads, but zeros are fine: the effect uses only uv. Draw
the mesh with the state the card uses:
gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);
gl.disable(gl.BLEND);
gl.enable(gl.CULL_FACE); // back faces culled: the holes show what is behind
Set p_amount every frame from your own animation. A burn that takes two seconds after a hit is a clamped ramp:
const amount = Math.min((now - hitTime) / 2000, 1);
gl.uniform1f(gl.getUniformLocation(program, "p_amount"), amount);
Color uniforms are linear: convert the sRGB values from a color picker before uploading them, as the renderer on this
site does. The output is clamped and encoded to sRGB in main(), so draw straight to the canvas.
Make it in Godot
In a VisualShader, plug a Texture2D node holding a NoiseTexture2D (Seamless on) in place of tiledNoise, build the threshold and the two bands with Mix and SmoothStep nodes, and connect the alpha to Alpha, and a FloatConstant of 0.5 to Alpha Scissor Threshold on the output node.
Create a ShaderMaterial on the MeshInstance3D, paste the shader into a new Shader, and the mesh burns at the default
amount. The shader writes ALPHA_SCISSOR_THRESHOLD, which keeps the material in the opaque pipeline. For a thin, open
mesh such as a letter or a flag, add cull_disabled to the render_mode so its back side burns too.
Animate the amount uniform from a script. A Tween is the shortest way to burn an enemy away in two seconds and free
it afterwards:
func burn_away() -> void:
var material := $Mesh.get_active_material(0) as ShaderMaterial
var tween := create_tween()
tween.tween_method(func(v: float) -> void: material.set_shader_parameter("amount", v), 0.0, 1.0, 2.0)
tween.tween_callback(queue_free)
Each instance that burns on its own schedule needs its own value. Either duplicate the material per instance
(material.duplicate()), or change uniform float amount to instance uniform float amount and set it with
$Mesh.set_instance_shader_parameter("amount", v), which keeps one shared material.
The color params use source_color, so the inspector’s color picker gives sRGB and Godot decodes it to linear before
the effect sees it, the same as on this site. With Glow enabled in the WorldEnvironment and the ember written to
EMISSION as well, the edge blooms.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// Dissolve → Burning edge, 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_amount; // Burnt: 0.4 (0.0 to 1.0)uniform float p_scale; // Blotch count: 10.0 (2.0 to 16.0)uniform float p_width; // Edge width: 0.04 (0.005 to 0.15)uniform vec3 p_base; // Surface: vec3(0.62, 0.66, 0.72), sRGB; upload it decoded to linearuniform vec3 p_glow; // Ember: vec3(1.0, 0.55, 0.12), 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));}// The integer hash from the hash fundamental (MurmurHash3's finalizer, public domain): the same bits on every GPU,// where the one-line sine hash depends on how precisely each GPU computes sin of a large argumentuint mix32(uint x) { x ^= x >> 16u; x *= 0x85ebca6bu; x ^= x >> 13u; x *= 0xc2b2ae35u; x ^= x >> 16u; return x;}float hashCell(ivec2 cell) { uint h = mix32(uint(cell.x) ^ mix32(uint(cell.y))); return float(h >> 8u) / 16777216.0;}// Value noise whose cells wrap every `period` cells. The sphere's u runs once around it, so without the wrap the noise// would jump where u goes from 1 back to 0 and draw a seam down its side. The wrap is integer %, which is exact where// float mod can round 20/20 below 1 and bring the seam back; cells are never negative on a 0..1 UV, so % is defined.float tiledNoise(vec2 p, ivec2 period) { ivec2 cell = ivec2(floor(p)); vec2 f = fract(p); float a = hashCell(cell % period); float b = hashCell((cell + ivec2(1, 0)) % period); float c = hashCell((cell + ivec2(0, 1)) % period); float d = hashCell((cell + ivec2(1, 1)) % period); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}vec4 effect(Inputs i) { // Around the sphere is twice as far as pole to pole, so twice the cells keep the blotches round ivec2 period = ivec2(2, 1) * int(p_scale); float n = tiledNoise(i.uv * vec2(period), period); // Starts below 0 so that at amount 0 not even the charred band shows, and reaches 1, past every noise value float threshold = mix(-3.0 * p_width, 1.0, p_amount); float burnt = step(n, threshold); float charred = 1.0 - smoothstep(threshold, threshold + 3.0 * p_width, n); float edge = 1.0 - smoothstep(threshold, threshold + p_width, n); vec3 surface = mix(p_base, p_base * 0.08, charred); vec3 color = mix(surface, p_glow, edge); return vec4(color, 1.0 - burnt);}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); // A cut-out: fragments under the threshold are dropped and the rest stay opaque if (frag_out.a < 0.5) discard; // 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);}// Dissolve → Burning edge, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float amount : hint_range(0.0, 1.0, 0.01) = 0.4;uniform float scale : hint_range(2.0, 16.0, 1.0) = 10.0;uniform float width : hint_range(0.005, 0.15, 0.005) = 0.04;uniform vec3 base : source_color = vec3(0.62, 0.66, 0.72);uniform vec3 glow : source_color = vec3(1.0, 0.55, 0.12);struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// The integer hash from the hash fundamental (MurmurHash3's finalizer, public domain): the same bits on every GPU,// where the one-line sine hash depends on how precisely each GPU computes sin of a large argumentuint mix32(uint x) { x ^= x >> 16u; x *= 0x85ebca6bu; x ^= x >> 13u; x *= 0xc2b2ae35u; x ^= x >> 16u; return x;}float hashCell(ivec2 cell) { uint h = mix32(uint(cell.x) ^ mix32(uint(cell.y))); return float(h >> 8u) / 16777216.0;}// Value noise whose cells wrap every `period` cells. The sphere's u runs once around it, so without the wrap the noise// would jump where u goes from 1 back to 0 and draw a seam down its side. The wrap is integer %, which is exact where// float mod can round 20/20 below 1 and bring the seam back; cells are never negative on a 0..1 UV, so % is defined.float tiledNoise(vec2 p, ivec2 period) { ivec2 cell = ivec2(floor(p)); vec2 f = fract(p); float a = hashCell(cell % period); float b = hashCell((cell + ivec2(1, 0)) % period); float c = hashCell((cell + ivec2(0, 1)) % period); float d = hashCell((cell + ivec2(1, 1)) % period); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}vec4 effect(Inputs i) { // Around the sphere is twice as far as pole to pole, so twice the cells keep the blotches round ivec2 period = ivec2(2, 1) * int(scale); float n = tiledNoise(i.uv * vec2(period), period); // Starts below 0 so that at amount 0 not even the charred band shows, and reaches 1, past every noise value float threshold = mix(-3.0 * width, 1.0, amount); float burnt = step(n, threshold); float charred = 1.0 - smoothstep(threshold, threshold + 3.0 * width, n); float edge = 1.0 - smoothstep(threshold, threshold + width, n); vec3 surface = mix(base, base * 0.08, charred); vec3 color = mix(surface, glow, edge); return vec4(color, 1.0 - burnt);}void fragment() { Inputs i; // Godot's UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // VERTEX, NORMAL and VIEW are in view space here; the effect works in world space i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz); i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = VIEWPORT_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); ALBEDO = frag_out.rgb; // A cut-out: Godot drops fragments under the threshold and keeps the rest opaque ALPHA = frag_out.a; ALPHA_SCISSOR_THRESHOLD = 0.5;}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.