Skip to content
Reveal · DissolveBeginner

Burning edge

A surface eaten by growing holes, each ringed by a charred band and a thin glowing ember.

UVTimeSurface
Open in Playground
  1. 01GoalReveal
  2. 02TechniqueDissolve
  3. 03ExecutionBurning edge
Test mesh

Parameters

0.40
10
0.040
#9ea8b8
#ff8c1f

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. tiledNoiseTexture2DtiledNoisetiledNoisetiledNoise
    UV
    Time

    01 Noise

  2. mixMixmixmixmix

    02 Threshold

  3. stepStepstepstepstep

    03 Cut

  4. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    04 Char band

  5. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    05 Ember band

  6. mixMixmixmixmix

    06 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

tiledNoise is the value noise from the noise fundamental: a hash at the four corners of each grid cell, blended with the smoothstep curve. It runs on the mesh’s UV, so the blotches stick to the surface as it turns. The hash is the integer one from the hash fundamental, hashCell, 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

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 clip 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 smoothstep over a range three times narrower gives a thin line right on the edge. Edge width scales both bands together, so the char always stays wider than the ember.

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

burning-edge.fragGLSL
#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);}
burning-edge.gdshaderGodot shader
// 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.

Further reading