Quick answer: Visual Shader inserts implicit type conversions, defaults to medium float precision, treats Texture2D as sRGB, and processes normals in tangent space — equivalent text shaders typically default to high precision, raw sampling, and view-space normals. Right-click the resource and Convert to Shader to see the generated GDShader, then lock precision with render_mode highp, set explicit texture hints, and unpack normals yourself when you need a non-tangent space.
Here is how to fix a Godot Visual Shader that produces different visuals than the equivalent hand-written shader code, even though every node maps to a function you can name. You build a Visual Shader graph that should look exactly like your reference text shader, plug them both into identical materials, and the colors are off by a few percent. Or the normal map looks washed out. Or there’s subtle banding in your gradient that the text shader does not have. The Visual Shader is not lying — it is doing exactly what its inspector graph says — but the inspector hides several decisions Godot makes for you.
The Symptom
You wire up a Visual Shader graph that mirrors a hand-written shader. The graph compiles. The material renders. And:
Colors are slightly washed. Albedo from a Visual Shader Texture node is brighter and lower-contrast than the same texture sampled with texture() in a text shader. Hex picking the result against the source reveals roughly an inverse-gamma relationship.
Normal maps look flat. Tangent-space normal maps fed through a Visual Shader Texture node produce less surface relief than the text-shader equivalent. Or, conversely, normals look completely wrong — like the X axis is flipped or the Z component is missing.
Banding in long gradients. A gradient that takes the screen from black to white shows visible stair-stepping in the Visual Shader version. The text shader version is smooth. The difference is most obvious on mobile and on GLES backends.
Math operations diverge for large or small values. A computation involving multiplication of large numbers or division by small ones produces noticeably different results between the two shaders. The Visual Shader version saturates or rounds to zero earlier.
What Causes This
Implicit type conversions. Visual Shader graphs are strongly typed but the editor inserts converter nodes silently when you connect, say, a Float output into a Vector3 input. The conversion broadcasts the float to all three channels, which is usually what you want — but if you connected a Float into a Vec4 input expecting alpha, you got vec4(f, f, f, f) instead of vec4(rgb, f). The text shader equivalent depends on which line you actually wrote.
Default float precision differs. Visual Shader emits mediump qualifiers for most intermediate values on mobile and GLES backends. Hand-written shaders without an explicit precision qualifier default to highp. mediump is a 16-bit float; highp is 32-bit. Long gradients, very small numbers, and accumulated multiplications all suffer at 16 bits.
Texture nodes default to sRGB. A Visual Shader Texture2D node has a Source property that defaults to SOURCE_TEXTURE, which respects the texture’s import settings. If the texture is imported as sRGB (the default for color images in Godot), Godot converts to linear at sample time. If your text shader uses texture(sampler, uv) on a sampler declared without hint_albedo, no conversion happens, and you get the raw sRGB-encoded value.
Normal vector space mismatch. The Normal output of a Visual Shader spatial shader is in view space; texture nodes feeding the Normal Map input are interpreted as tangent space and unpacked using the vertex tangent. If your text shader sampled a world-space normal texture and used it directly, the Visual Shader path will not match because the conversion paths are different.
The Fix
Step 1: Convert the Visual Shader to text and diff. The single most effective debugging step is to right-click the Visual Shader resource in the inspector and choose Convert to Shader. The result is a regular .gdshader file containing exactly what the engine compiles. Save it next to your reference shader and run a diff.
# shader_type spatial
# Visual Shader output (after Convert to Shader)
render_mode blend_mix, depth_draw_opaque, cull_back;
uniform sampler2D tex_albedo : source_color;
void fragment() {
// Note the implicit conversion to vec3 here
vec4 sampled = texture(tex_albedo, UV);
ALBEDO = sampled.rgb;
}
Compare this to your hand-written reference. Every implicit cast, every precision qualifier, every sampler hint that differs is a candidate root cause.
Step 2: Lock precision with render_mode highp. Add render_mode highp to the top of both shaders. This forces 32-bit floats everywhere, eliminates the banding, and removes one big source of cross-shader divergence.
shader_type spatial;
render_mode highp, blend_mix, cull_back;
uniform sampler2D albedo_tex : source_color, hint_albedo;
uniform sampler2D normal_tex : hint_normal;
void fragment() {
vec4 a = texture(albedo_tex, UV);
ALBEDO = a.rgb;
// Explicit unpack: x and y in [-1, 1], z reconstructed
vec3 n = texture(normal_tex, UV).xyz;
n.xy = n.xy * 2.0 - 1.0;
n.z = sqrt(clamp(1.0 - dot(n.xy, n.xy), 0.0, 1.0));
NORMAL_MAP = n;
}
For the Visual Shader version, set the render_mode in the resource’s inspector to include High Precision. Both shaders now use the same float width and produce visually identical gradients.
Configure Texture Hints Explicitly
On every Texture2D Visual Shader node, expand the inspector and set the texture hint:
Albedo / source_color textures should have hint_albedo (or source_color in 4.x). Godot will convert sRGB to linear at sample time, matching what a regular SpatialMaterial does.
Normal maps should have hint_normal. Godot does not gamma-correct, packs the texture into BC5 / RG-only on import, and reconstructs Z if it is missing. The text-shader equivalent must use the same : hint_normal annotation.
Data textures — lookup tables, masks, packed values — should have hint_default_white or hint_default_black with no source_color. Otherwise Godot inverse-gammas your data and breaks every lookup.
Handle Normal Vector Spaces Explicitly
If you need a normal in world space rather than tangent space — common for environmental effects like triplanar mapping or world-aligned dust — do the conversion yourself rather than feeding a non-tangent map into the Normal Map slot. The Visual Shader will assume tangent-space, and the unpacking math will distort your input.
void fragment() {
// Tangent-space normal from texture
vec3 n_ts = texture(normal_tex, UV).xyz * 2.0 - 1.0;
n_ts.z = sqrt(clamp(
1.0 - dot(n_ts.xy, n_ts.xy), 0.0, 1.0));
// Build TBN and convert to world space ourselves
mat3 tbn = mat3(TANGENT, BINORMAL, NORMAL);
vec3 n_ws = normalize(tbn * n_ts);
// Use n_ws for triplanar / world-aligned effects
NORMAL = n_ws; // note: NORMAL is view-space, conversion still needed
}
The point is to make every conversion visible in code. Visual Shader hides them; the text version cannot.
“A Visual Shader is a UI on top of generated code. When the UI lies, ask for the code — the engine’s ‘Convert to Shader’ button is the only ground truth.”
Related Issues
If both shader paths look correct in the editor but the build is different, see Godot Shader Different in Export Build. For Forward+ vs Mobile renderer differences, check Renderer Mode Color Mismatch.
Convert to Shader, diff, then fix. Visual Shader hides three decisions that you have to opt into manually.