If you've been clicking nodes in Godot 4's VisualShader editor and wondering why the output never quite matches what you imagined, the answer is almost always the same: you need to write the shader yourself. Godot's ShaderMaterial with hand-written GLSL gives you complete control over every vertex, every fragment, and every uniform — and it's far less intimidating than it looks.
Why Write GLSL Shaders Directly in Godot 4?
VisualShader is a great prototyping tool, but it generates verbose GLSL under the hood, hides the underlying math, and makes it hard to share or version-control your work. Raw shader code is just a .gdshader text file — it diffs cleanly in Git, runs without conversion overhead, and exposes the exact properties Godot's rendering pipeline expects.
Godot 4 uses a custom GLSL dialect built on top of OpenGL ES 3.0/Vulkan. You declare a shader_type, then write vertex() and fragment() functions using Godot's built-in varying variables. The renderer handles the matrix math boilerplate; you focus on the creative logic. The performance case is equally strong: a hand-written shader uses exactly the instructions it needs, whereas VisualShader graphs often produce redundant nodes, extra texture samples, and intermediate variables a skilled artist would never write by hand.
Setting Up Your First ShaderMaterial
Start with a MeshInstance3D in your scene. In the Inspector, expand Surface Material Override, click the slot, and choose New ShaderMaterial. Click the new material, then click Shader → New Shader. Godot opens an inline editor at the bottom of the viewport.
The minimal shader is four lines: shader_type spatial; at the top, then void fragment() { ALBEDO = vec3(1.0, 0.0, 0.5); ROUGHNESS = 0.4; }. Save — the mesh turns pink immediately. Godot injects built-in output variables (ALBEDO, ROUGHNESS, METALLIC, EMISSION, NORMAL, ALPHA) automatically; you don't declare them. shader_type spatial targets the 3D PBR renderer (alternatives: canvas_item for 2D, particles). Every save recompiles and hot-reloads.
![]()
Vertex Shaders — Animated Displacement and Wind Effects
The vertex() function runs once per mesh vertex on the GPU and lets you offset position, animate UV coordinates, or compute custom normals before rasterization. A classic use case is foliage wind sway: a sine-wave displacement driven by TIME. Multiplying displacement by UV.y anchors the base of the plant while the tips sway — simple but convincing for grass, crops, or tree leaves. Export wind_strength and wind_speed as uniform values so level designers can tune them without touching shader code.
shader_type spatial;
uniform float wind_strength : hint_range(0.0, 1.0) = 0.1;
uniform float wind_speed : hint_range(0.1, 5.0) = 1.5;
void vertex() {
float wave = sin(TIME * wind_speed + VERTEX.x * 2.0) * wind_strength;
VERTEX.x += wave * UV.y; // UV.y = 0 at base, 1 at tip — anchors the roots
VERTEX.z += wave * 0.5 * UV.y;
}
void fragment() {
ALBEDO = vec3(0.15, 0.55, 0.1);
ROUGHNESS = 0.85;
}
Key built-in vertex variables: VERTEX (local position, vec3, writable), NORMAL (vec3, writable), UV / UV2 (vec2, read-only), COLOR (vertex color, vec4), TIME (elapsed seconds), MODEL_MATRIX (object-to-world, mat4).
Fragment Shaders — PBR Properties, Rim Light, and Emission
The fragment() function determines the final appearance of each pixel. Godot's spatial renderer is physically-based, so the outputs map directly to PBR: ALBEDO is base color, ROUGHNESS and METALLIC control specular response, and EMISSION adds self-illuminating color that ignores scene lighting.
Here's a sci-fi panel material combining sampled PBR textures with procedural rim light. VIEW points from the surface toward the camera; when dot(NORMAL, VIEW) is near zero the surface is edge-on — that's where rim light appears. Raising it to a power tightens the halo to a crisp edge rather than a diffuse glow.
shader_type spatial;
uniform sampler2D albedo_tex : source_color, hint_default_white;
uniform sampler2D orm_tex : hint_default_white; // R=AO, G=Roughness, B=Metallic
uniform vec4 emission_color : source_color = vec4(0.0, 0.8, 1.0, 1.0);
uniform float emission_strength : hint_range(0.0, 8.0) = 2.0;
uniform float rim_strength : hint_range(0.0, 1.0) = 0.4;
void fragment() {
ALBEDO = texture(albedo_tex, UV).rgb;
vec3 orm = texture(orm_tex, UV).rgb;
AO = orm.r; ROUGHNESS = orm.g; METALLIC = orm.b;
float rim = 1.0 - clamp(dot(NORMAL, VIEW), 0.0, 1.0);
EMISSION = emission_color.rgb * pow(rim, 3.0) * rim_strength * emission_strength;
}
![]()
For assets from BitSoul's marketplace, ORM-packed textures (Occlusion in R, Roughness in G, Metallic in B) are the standard format. One texture sample covers three PBR channels, saving two texture fetches per pixel.
Shader Uniforms and GDScript Integration
Uniforms declared in a shader automatically appear as editable properties on the ShaderMaterial in the Inspector. Drive them from GDScript at runtime for state-driven effects like damage flashes or energy charge-ups. Use set_shader_parameter(name, value) — the Godot 4 API; set_shader_param() was removed in Godot 4 and will silently fail. Cache the material reference in _ready() rather than calling get_surface_override_material() every frame.
var _mat: ShaderMaterial
var _charge: float = 0.0
@export var charge_speed: float = 2.0
func _ready() -> void:
_mat = get_surface_override_material(0) as ShaderMaterial
func _process(delta: float) -> void:
_charge = clamp(_charge + delta * charge_speed, 0.0, 1.0)
_mat.set_shader_parameter("emission_strength", _charge * 8.0)
The name string must match the uniform identifier exactly, including case. You can pass float, int, bool, Vector2, Vector3, Color, Transform3D, and Texture2D objects directly.
Performance Tips and When to Use VisualShader Instead
Hand-written shaders are not always the right tool. Use VisualShader when prototyping a blend effect, when a technical artist without GLSL experience owns the asset, or when you need to visually debug intermediate values using the Preview node.
Stick to GLSL when you need version control and code review on shader logic, when the shader will be generated or templated programmatically, or when performance profiling shows the VisualShader output is doing redundant work.
Godot 4's shader compiler runs on first use, which can cause a stutter. Warm it at load time by calling RenderingServer.shader_compile_spirv_from_glsl() off the main thread, or enable Godot's built-in PSO caching in Project Settings → Rendering → Shaders → Shader Compilation Mode.
Texture lookups are the main GPU cost in fragment shaders. For game assets on BitSoul's marketplace, check whether the asset already includes channel-packed ORM textures before writing custom sampling logic — one sample is always faster than three.
This post is part of the Ultimate Guide to Free 3D Game Assets — BitSoul's complete reference for formats, texturing, rigging, optimization, and engine integration.
Need drop-in assets for this workflow? Grab the 70-model Food Bundle on the BitSoul marketplace and drop them straight into your project.