Skip to content
Stylize · OutlinesIntermediate

Inverted-hull outline

The mesh drawn twice, the second time inflated, inside out and in ink, so a rim shows around the silhouette.

UVPositionNormalScreenVertex
Open in Playground
  1. 01GoalStylize
  2. 02TechniqueOutlines
  3. 03ExecutionInverted-hull outline
Test mesh

Parameters

0.040
#1a121f
#6bbde6
#4566a8

Copy for your platform

  • GLSLAdapt
  • GodotAdapt

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. uniformColorParameteruniformuniformuniform
    UV
    Position
    Normal
    Screen

    01 Grown hull, back faces only

    Pass hull

  2. dotDotProductdotdotdot

    02 Light facing

    Pass main

  3. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    03 Two bands

    Pass main

  4. mixMixmixmixmix

    04 Mesh over the hull

    Pass main

How it works

This is the oldest real-time outline trick, and it uses no image processing at all. The mesh is drawn twice. The first draw is the outline: a slightly bigger copy of the mesh, turned inside out. The second draw is the mesh itself, which covers almost all of the first.

1. Grow the hull. Pass hull has a vertex entry, extrude, that moves every vertex along its normal by the Line width, in world units. On a sphere that just makes a bigger sphere; on any smooth mesh it makes a shell around it.

2. Keep only its back faces. The same pass culls front faces instead of back faces. Seen from the camera, only the inside of the far half of the shell is left, painted flat in the line color. The step preview shows it alone: a disc of ink, a little larger than the mesh.

3. Draw the mesh over it. Pass main draws the real mesh with normal culling, sharing the depth buffer with the hull. The mesh is in front of the hull’s far side everywhere they overlap, so the depth test hides the hull behind it. What survives is the ring of hull that sticks out past the mesh’s silhouette: the outline.

4. Shade the mesh. The mesh itself gets a two-band toon light (a dot product with a fixed sun, cut by a narrow smoothstep), so the card shows the outline in the style it is usually paired with.

Why culling front faces works

A closed mesh seen from outside shows its front faces and hides its back faces behind them. Cull the front faces and you see the back faces through the hole, the inside of the far wall. That far wall is always behind the real mesh, which is why the real mesh wins the depth test everywhere except at the rim. It’s also why the hull never covers anything in front of its mesh: it sits behind the mesh, so geometry closer to the camera still wins.

Where it breaks

  • Hard edges. A cube has separate vertices at each corner, one per face, each with its own normal. Pushed apart along different normals, the faces separate and leave gaps in the line. Use smoothed normals for the hull, often baked into a second normal set or vertex colors, and keep the hard normals for lighting.
  • Width in world units. A line 0.04 units wide is thick up close and thin far away. Scaling the width by the distance to the camera keeps it constant on screen; that needs the camera position in the vertex stage.
  • Contours only. An arm in front of the body gets its line, because the arm’s hull sits in front of the body; but a crease, or a fold where two surfaces nearly touch, is closer than the line width and gets none. Lines there, and inside flat areas, need edge detection.
  • Twice the vertex work. Every outlined mesh is drawn twice, so a crowd of outlined characters doubles its vertex cost. The hull is drawn first, so most of its pixels are shaded and then covered by the mesh, but its fragment shader only returns a color, so that overdraw is cheap.

Make it in GLSL

Two passes over the same mesh and one depth buffer. Pass "hull" (its vertex shader runs extrude, its fragment shader outline) is drawn first with gl.cullFace(gl.FRONT); pass "main" is drawn second with gl.cullFace(gl.BACK). Depth testing and depth writes stay on for both. The copy button gives three shaders, pass "hull"'s vertex and fragment shaders and then pass "main"'s fragment shader; pass "main" needs a plain vertex shader that writes the same varyings, which is the hull's vertex shader without the `i = extrude(i);` line.

Compile two programs: the hull program from the copied vertex and fragment shaders, and the main program from the copied main fragment shader plus a copy of the hull vertex shader with the extrude() call removed. Both vertex shaders read the same attributes (a_position, a_normal, a_uv, in world space here, since the shaders apply no model matrix) and the same uniforms: u_viewProjection, u_camera and the params. Set the params on both programs, because each one declares all of them.

Each frame, clear color and depth, then:

  1. gl.enable(gl.CULL_FACE), gl.cullFace(gl.FRONT), use the hull program and draw the mesh.
  2. gl.cullFace(gl.BACK), use the main program and draw the same mesh again.

The depth test gives the same picture in either order. Drawing the main mesh first is slightly cheaper, because the early depth test then skips the hull’s hidden pixels before their fragment shader runs; that shader only returns a color, though, so the difference is small.

If your mesh has a model matrix, apply it to the position and normal before extrude() runs, so the line width stays in world units whatever the object’s scale.

Make it in Godot

Two materials on one MeshInstance3D. The first shader (pass "hull", render_mode cull_front) goes in the mesh's ShaderMaterial; the second (pass "main") goes in a second ShaderMaterial set as the first one's Next Pass. Godot draws the next pass right after, with the same depth buffer.

Select the MeshInstance3D and create a new ShaderMaterial in its Material Override (or surface material). Paste the first shader, the one whose header says pass “hull” (1 of 2), into a new Shader there. In that material, open Next Pass, create another ShaderMaterial and paste the second shader into it.

The base material draws the grown hull with its front faces culled, and Godot then draws the next pass, the toon-lit mesh, into the same depth buffer, which hides everything of the hull except the rim. Both are unshaded, so the colors are exactly the preview’s; set the WorldEnvironment’s Tonemap Mode to Linear to keep them that way.

Without code, the same trick is a StandardMaterial3D for the mesh with a second StandardMaterial3D in Next Pass that is unshaded, has Cull Mode set to Front, the line color as Albedo and Grow turned on with a small Grow Amount.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

inverted-hull-outline.glslGLSL
#version 300 es// Outlines → Inverted-hull outline, pass "hull" (1 of 2), vertex shader, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform mat4 u_viewProjection;uniform vec3 u_camera;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_width; // Line width: 0.04 (0.0 to 0.15)uniform vec3 p_lineColor; // Line color: vec3(0.1, 0.07, 0.12), sRGB; upload it decoded to linearuniform vec3 p_base; // Base color: vec3(0.42, 0.74, 0.9), sRGB; upload it decoded to linearuniform vec3 p_shadow; // Shadow color: vec3(0.27, 0.4, 0.66), sRGB; upload it decoded to linearlayout(location = 0) in vec3 a_position;layout(location = 1) in vec3 a_normal;layout(location = 2) in vec2 a_uv;out vec2 v_uv;out vec3 v_position;out vec3 v_normal;out vec3 v_view;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// The hull is the same mesh pushed out along its normals. Pass "hull" culls its front faces, so only the far inside of// the shell is drawn; the mesh drawn after it covers all of that except the rim that sticks out past its silhouette.Inputs extrude(Inputs i) {	i.position += i.normal * p_width;	return i;}vec4 outline(Inputs i) {	vec3 ink = p_lineColor;	return vec4(ink, 1.0);}vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(0.08, 0.12, lambert);	vec3 color = mix(p_shadow, p_base, lit);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = a_uv;	// The test meshes have no model transform: object space is world space	i.position = a_position;	i.normal = normalize(a_normal);	i.view = normalize(u_camera - a_position);	i.time = u_time;	// No pixel yet in a vertex shader	i.fragCoord = vec2(0.0);	i.resolution = u_resolution;	i = extrude(i);	v_uv = a_uv;	v_position = i.position;	v_normal = i.normal;	// From the moved surface toward the camera; the fragment shader normalizes it	v_view = u_camera - i.position;	gl_Position = u_viewProjection * vec4(i.position, 1.0);}#version 300 es// Outlines → Inverted-hull outline, pass "hull" (1 of 2), 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_width; // Line width: 0.04 (0.0 to 0.15)uniform vec3 p_lineColor; // Line color: vec3(0.1, 0.07, 0.12), sRGB; upload it decoded to linearuniform vec3 p_base; // Base color: vec3(0.42, 0.74, 0.9), sRGB; upload it decoded to linearuniform vec3 p_shadow; // Shadow color: vec3(0.27, 0.4, 0.66), 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 hull is the same mesh pushed out along its normals. Pass "hull" culls its front faces, so only the far inside of// the shell is drawn; the mesh drawn after it covers all of that except the rim that sticks out past its silhouette.Inputs extrude(Inputs i) {	i.position += i.normal * p_width;	return i;}vec4 outline(Inputs i) {	vec3 ink = p_lineColor;	return vec4(ink, 1.0);}vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(0.08, 0.12, lambert);	vec3 color = mix(p_shadow, p_base, lit);	return vec4(color, 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 = outline(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);}#version 300 es// Outlines → Inverted-hull outline, pass "main" (2 of 2), 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_width; // Line width: 0.04 (0.0 to 0.15)uniform vec3 p_lineColor; // Line color: vec3(0.1, 0.07, 0.12), sRGB; upload it decoded to linearuniform vec3 p_base; // Base color: vec3(0.42, 0.74, 0.9), sRGB; upload it decoded to linearuniform vec3 p_shadow; // Shadow color: vec3(0.27, 0.4, 0.66), 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 hull is the same mesh pushed out along its normals. Pass "hull" culls its front faces, so only the far inside of// the shell is drawn; the mesh drawn after it covers all of that except the rim that sticks out past its silhouette.Inputs extrude(Inputs i) {	i.position += i.normal * p_width;	return i;}vec4 outline(Inputs i) {	vec3 ink = p_lineColor;	return vec4(ink, 1.0);}vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(0.08, 0.12, lambert);	vec3 color = mix(p_shadow, p_base, lit);	return vec4(color, 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);}
inverted-hull-outline.gdshaderGodot shader
// Outlines → Inverted-hull outline, pass "hull" (1 of 2), Godot 4. From Creative Center, MIT license.// Godot draws a material's next_pass after it: this is the ShaderMaterial, and pass "main"'s shader its next_pass.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch it// world_vertex_coords: vertex() sees VERTEX and NORMAL in world space, as the effect's vertex entry expectsrender_mode unshaded, world_vertex_coords, cull_front;uniform float width : hint_range(0.0, 0.15, 0.005) = 0.04;uniform vec3 lineColor : source_color = vec3(0.1, 0.07, 0.12);uniform vec3 base : source_color = vec3(0.42, 0.74, 0.9);uniform vec3 shadow : source_color = vec3(0.27, 0.4, 0.66);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// The hull is the same mesh pushed out along its normals. Pass "hull" culls its front faces, so only the far inside of// the shell is drawn; the mesh drawn after it covers all of that except the rim that sticks out past its silhouette.Inputs extrude(Inputs i) {	i.position += i.normal * width;	return i;}vec4 outline(Inputs i) {	vec3 ink = lineColor;	return vec4(ink, 1.0);}vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(0.08, 0.12, lambert);	vec3 color = mix(shadow, base, lit);	return vec4(color, 1.0);}void vertex() {	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);	i.position = VERTEX;	i.normal = normalize(NORMAL);	i.view = normalize(CAMERA_POSITION_WORLD - VERTEX);	i.time = TIME;	// No pixel yet in vertex()	i.fragCoord = vec2(0.0);	i.resolution = VIEWPORT_SIZE;	i = extrude(i);	VERTEX = i.position;	NORMAL = i.normal;}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 = outline(i);	ALBEDO = frag_out.rgb;}// Outlines → Inverted-hull outline, pass "main" (2 of 2), Godot 4. From Creative Center, MIT license.// The next_pass of pass "hull"'s material.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 width : hint_range(0.0, 0.15, 0.005) = 0.04;uniform vec3 lineColor : source_color = vec3(0.1, 0.07, 0.12);uniform vec3 base : source_color = vec3(0.42, 0.74, 0.9);uniform vec3 shadow : source_color = vec3(0.27, 0.4, 0.66);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// The hull is the same mesh pushed out along its normals. Pass "hull" culls its front faces, so only the far inside of// the shell is drawn; the mesh drawn after it covers all of that except the rim that sticks out past its silhouette.Inputs extrude(Inputs i) {	i.position += i.normal * width;	return i;}vec4 outline(Inputs i) {	vec3 ink = lineColor;	return vec4(ink, 1.0);}vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(0.08, 0.12, lambert);	vec3 color = mix(shadow, base, lit);	return vec4(color, 1.0);}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;}

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