Wireframes with the Geometry Shader in Unity URP
Wireframes are useful in a variety of instances when you are developing. This article will show you the process for calculating wireframes…
Wireframes with the Geometry Shader in Unity URP

Runtime Wireframe shader utilizing an HLSL geometry shader
Wireframes are useful in a variety of instances when you are developing. This article will show you the process for calculating wireframes using an HLSL geometry shader (note: not a performant approach to wireframes, please precompute your wireframes or use a fragment shader instead if scaling this up for any kind of serious production). In a sense, this will be more of a teaching tool on HLSL shaders and geometry shaders than it will be on building a practical effect.
Typically when we work with HLSL shaders, we work with the fragment and vertex stages of the shader. More or less, the fragment determines how the surface of the object looks and reacts to light, while the vertex determines how the object is shaped, morphed, deformed, etc. The geometry shader is a shader that processes geometry data, such as vertices and primitives, to generate or modify additional geometry before rasterization. It operates between the vertex and pixel shaders, enabling effects like dynamic tessellation, procedural mesh expansion, or, in our case, a wireframe.
Building a Geometry Shader for Wireframe Rendering in Unity URP
This tutorial will guide you step by step to create a custom geometry shader for rendering wireframes in Unity’s Universal Render Pipeline (URP). A geometry shader is a powerful tool that operates between the vertex and fragment stages, allowing us to generate additional geometry or modify existing geometry dynamically.
By the end of this lesson, you’ll have a shader that renders a wireframe overlay on 3D objects with adjustable thickness and color. Let’s start by breaking the shader into sections, building it up piece by piece.
1. Initial Shader Setup
We start with a basic shader structure that defines the properties for the wireframe effect, including colors and thickness, and sets up the necessary render pipeline tags and pass settings.
Shader "Custom/GeometryWireframeURP"
{
Properties
{
_WireColor("Wire Color", Color) = (1, 1, 1, 1) // Wireframe color
_BaseColor("Base Color", Color) = (0, 0, 0, 1) // Base fill color
_WireThickness("Wire Thickness", Float) = 0.02 // Thickness of wireframe
}
SubShader
{
Tags { "RenderPipeline" = "UniversalRenderPipeline" }
Pass
{
Name "Wireframe"
Cull Off
ZWrite On
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float4 _WireColor;
float4 _BaseColor;
float _WireThickness;
struct appdata
{
float3 positionOS : POSITION; // Object-space position
};
struct v2g
{
float4 positionCS : SV_POSITION; // Clip-space position
float3 worldPos : TEXCOORD0; // World-space position
};
ENDHLSL
}
}
}
I like to think about HLSL shaders as containing a number of blocks that we are simply putting together to achieve the effect. Different blocks have different jobs when it comes to handling data. Some store the data, preparing it for use, and others use it. The ones that use the data are the actual functions in the Pass (e.g., the Fragment Shader, the Vertex Shader, and the Geometry Shader). As we create different blocks, I will share what they do with you.
Properties Block:
Defines parameters that can be adjusted in the Unity Editor.
- _WireColor: for the wireframe color.
- _BaseColor: for the object’s fill color, which we will leave as transparent black.
- _WireThickness: for the wireframe line thickness.
SubShader Tags: Ensures our shader’s compatibility with the Universal Render Pipeline in this case simply one line:
Tags { "RenderPipeline" = "UniversalRenderPipeline" }
Keywords:
Written immediately after the pass is created, provide certain qualities to our shader.
- Name — the name of our shader
- Cull Off — Both sides of geometry will be rendered, no back-face culling
- ZWrite-On — This shader will write to the depth buffer
- Blend — Here we can enable transparency blending for smooth visuals
Pragma/Include Statements:
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
Pragma statements declare which parts of the shader pass we will be using, Vert, Geom, and Frag in this case. The #include adds a necessary dependency for some of the HLSL functions we will be using and is specific to URP.
Pass Variable Declarations:
float4 _WireColor;
float4 _BaseColor;
float _WireThickness;
Although we already created these as properties, we need to declare them as variables in the shader pass before we can use them. If their name matches the property declaration then they will match the value of the property they are associated with.
Input Structs:
struct appdata
{
float3 positionOS : POSITION; // Object-space position
};
struct v2g
{
float4 positionCS : SV_POSITION; // Clip-space position
float3 worldPos : TEXCOORD0; // World-space position
};
Input structs are used to hold data that will be used in the shading functions of our pass. In this case, appdata holds the object-space position. Or the position relative to the object’s local origin. While v2g contains the clip-space position and the world space position.
Below is an image of how the render pipeline handles transformations in space during the rendering process. We start with Object space which as aforementioned is local to the origin of the object. This is passed through the model matrix to get to world space which is relative to the scene itself in Unity. Then we go through the view matrix to get to view space, this is the position relative to the camera’s position in unity. By going through the projection matrix we get the clip space image which is essentially our final product, the viewport transform applies a coordinate, but not a perspective change.

For more context, manipulating objects in different coordinate spaces is fundamental in computer graphics, as it allows for precise control over object positioning, orientation, and rendering.
Why Manipulate in Different Spaces?
- Local Transformations — adjusting an object’s shape or parts relative to its own origin is done in object space.
- Scene Arrangement — Placing objects within a scene requires positioning them in world space, ensuring objects are correctly located relative to others.
- Camera Perspective — Transforming coordinates to view space aligns the scene with the camera’s viewpoint, determining what the camera sees and how objects are projected
- Projection and Clipping — Converting to Clip Space applied perspective and orthographic projection, preparing the scene for rendering on a 2D screen and determining which objects are within the camera’s view.
- Screen Mapping — Finally, mapping coordinates to screen space translates the scene to pixel coordinates on the display, ensuring the rendered image aligns with the screen’s resolution and dimensions.
2. Adding the Vertex and Geometry Shaders
Next, we add the vertex shader which will be responsible for transforming vertex data from object space (local to model) to clip space (ready for rendering) and passes relevant information (like world-space position) to the next stage, the geometry shader, where the real magic happens.
Vertex Shader:
v2g vert(appdata v)
{
v2g o;
o.positionCS = TransformObjectToHClip(v.positionOS); // Transform to clip space
o.worldPos = TransformObjectToWorld(v.positionOS); // World position
return o;
}
Vertex Code Breakdown:
v2g vert(appdata v) //input
(appdata v) is the per-vertex input structure
- v.positionOS gives us the Object-space position of the vertex
v2g o;
(v2g o) is the output structure for passing data to the geometry shader
- o.positionCS: Vertex position transformed to clip space
- o.worldPos: Vertex position transformed to world space
TransformObjectToHClip (.positionOS )— converts the OS position to clip space
TransformObjectToWorld ( .positionOS ) — converts the OS position to world space
Geometry Shader:
[maxvertexcount(12)] // Each triangle emits 6 vertices (3 edges, each with thickness)
void geom(triangle v2g input[3], inout TriangleStream<g2f> triStream)
{
for (int i = 0; i < 3; ++i)
{
// Get edge vertices in clip space
float4 p1 = input[i].positionCS;
float4 p2 = input[(i + 1) % 3].positionCS;
// Calculate screen-space direction for thickness
float2 dir = normalize(p2.xy / p2.w - p1.xy / p1.w);
float2 perp = float2(-dir.y, dir.x);
// Adjust thickness in clip space
float2 offset = perp * _WireThickness * 0.5;
// Emit thick line as two triangles
g2f v;
v.color = _WireColor;
// First triangle of the line
v.positionCS = p1;
triStream.Append(v);
v.positionCS = float4(p1.xy + offset, p1.zw);
triStream.Append(v);
v.positionCS = float4(p2.xy + offset, p2.zw);
triStream.Append(v);
// Second triangle of the line
v.positionCS = p2;
triStream.Append(v);
v.positionCS = float4(p2.xy + offset, p2.zw);
triStream.Append(v);
v.positionCS = float4(p1.xy + offset, p1.zw);
triStream.Append(v);
}
}
Geometry Breakdown:
This geometry shader works by processing each triangle’s edges and emitting new geometry to create thick wireframe lines.
Input Triangles and Edge Processing:
- The geometry shader receives one triangle at a time (
triangle v2g input[3]). - Each triangle has three vertices (
input[0],input[1],input[2]). - The edges of the triangle are processed in a loop (
for (int i = 0; i < 3; ++i)).
Clip-Space Coordinates:
- Each vertex of the edge is converted to clip space (
input[i].positionCSandinput[(i + 1) % 3].positionCS). - Clip space is a normalized coordinate space used by the GPU for rendering.
Direction and Thickness:
- The direction vector (
dir) is calculated by normalizing the difference between the endpoints of the edge in screen space. - A perpendicular vector (
perp) to the direction is calculated, which defines the thickness of the line in screen space.
Offset Calculation:
- The perpendicular vector (
perp) is scaled by half the specified thickness (_WireThickness * 0.5) to create an offset. - This offset is applied to both endpoints of the edge to expand it into a thick line.
Emitting Vertices for Thick Lines:
- Two triangles are emitted for each edge to form a thick line:
First Triangle:
- Starts at the original vertex (
p1). - Moves to the offset position of the first vertex (
p1.xy + offset). - Ends at the offset position of the second vertex (
p2.xy + offset).
Second Triangle:
- Starts at the original second vertex (
p2). - Moves to the offset position of the second vertex (
p2.xy + offset). - Ends at the offset position of the first vertex (
p1.xy + offset).
Color Assignment:
- Each vertex emitted is assigned the wireframe color (
v.color = _WireColor).
Output Stream:
- Each emitted vertex is appended to the
TriangleStream(triStream.Append(v)), forming the thick wireframe.
3. Finalizing with the Fragment Shader
The fragment shader determines the color of each pixel in the rendered image. We ensure the wireframe lines use the specified color from the geometry shader.
half4 frag(g2f i) : SV_Target
{
return i.color; // Output the wireframe color
}
//yep its that simple
And finally, here is the finalized source code:
Shader "Custom/GeometryWireframeURP"
{
Properties
{
_WireColor("Wire Color", Color) = (1, 1, 1, 1)
_BaseColor("Base Color", Color) = (0, 0, 0, 1)
_WireThickness("Wire Thickness", Float) = 0.02
}
SubShader
{
Tags { "RenderPipeline" = "UniversalRenderPipeline" }
Pass
{
Name "Wireframe"
Cull Off
ZWrite On
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float4 _WireColor;
float4 _BaseColor;
float _WireThickness;
struct appdata
{
float3 positionOS : POSITION;
};
struct v2g
{
float4 positionCS : SV_POSITION;
float3 worldPos : TEXCOORD0;
};
struct g2f
{
float4 positionCS : SV_POSITION;
float4 color : COLOR;
};
v2g vert(appdata v)
{
v2g o;
o.positionCS = TransformObjectToHClip(v.positionOS);
o.worldPos = TransformObjectToWorld(v.positionOS);
return o;
}
[maxvertexcount(12)]
void geom(triangle v2g input[3], inout TriangleStream<g2f> triStream)
{
for (int i = 0; i < 3; ++i)
{
float4 p1 = input[i].positionCS;
float4 p2 = input[(i + 1) % 3].positionCS;
float2 dir = normalize(p2.xy / p2.w - p1.xy / p1.w);
float2 perp = float2(-dir.y, dir.x);
float2 offset = perp * _WireThickness * 0.5;
g2f v;
v.color = _WireColor;
v.positionCS = p1;
triStream.Append(v);
v.positionCS = float4(p1.xy + offset, p1.zw);
triStream.Append(v);
v.positionCS = float4(p2.xy + offset, p2.zw);
triStream.Append(v);
v.positionCS = p2;
triStream.Append(v);
v.positionCS = float4(p2.xy + offset, p2.zw);
triStream.Append(v);
v.positionCS = float4(p1.xy + offset, p1.zw);
triStream.Append(v);
}
}
half4 frag(g2f i) : SV_Target
{
return i.color; // Output the wireframe color
}
ENDHLSL
}
}
}
This should work on any mesh by generating new geometry for the wire- frame, which brings me to the final part of this article.
Considerations for Performance:
While this shader effectively creates thick wireframe lines, there are important performance considerations to keep in mind. Geometry shaders are inherently less performant than vertex or fragment shaders because they operate on entire primitives and can significantly amplify the vertex count, especially for high-polygon models. For example, this shader emits up to six vertices per triangle, which can strain the GPU pipeline in scenes with complex geometry. Additionally, the screen-space calculations for edge thickness and offsets add computational overhead that might not scale well for real-time applications. For better performance, alternative approaches like precomputed wireframes or fragment-based edge detection may be more suitable for production scenarios.
Congratulations! You’ve built a fully functional geometry shader for rendering wireframes in Unity URP. It isn’t the most practical effect in most scenarios, but it has taught us some of the basics of HLSL and hopefully opens the door to more experimentation on your part with the geometry shader. It can be costly, but as we have found, incredibly powerful.
메타데이터
- post_id
- e9d5dbe0d09f
- slug
- wireframes-with-the-geometry-shader-in-unity-urp-e9d5dbe0d09f
- url
- https://medium.com/@simon.swartout/wireframes-with-the-geometry-shader-in-unity-urp-e9d5dbe0d09f
- canonical_url
- https://medium.com/@simon.swartout/wireframes-with-the-geometry-shader-in-unity-urp-e9d5dbe0d09f
- author_url
- https://medium.com/@simon.swartout
- status
- ok
- fetched_at
- 2026-07-21 17:01:12