Shader Graph vs. Hand-Written GLSL/HLSL: When Visual Node Tools Start Costing You

359 views 10 replies

I've been using Unity's Shader Graph heavily for the past year and genuinely love it for prototyping — but I keep hitting walls that force me back to hand-written HLSL, and I think it's worth talking about where that line actually is.

Where Shader Graph Earns Its Keep

For surface effects — dissolve shaders, triplanar blending, stylized toon ramps — it's hard to beat the iteration speed. Being able to preview sub-expressions in the node graph catches mistakes in minutes that would take me half an hour to debug in raw code. Godot's visual shader editor has a similar feel, though the compiled output is noticeably cleaner in my experience.

Where It Breaks Down

The compiled HLSL output from Unity's Shader Graph is rough. I had a water shader that looked fine but was burning 2ms on mobile — dropped to hand-written code and got it to 0.4ms with no visual difference. The graph had generated redundant normalize() calls inside loops and wasn't reusing computed values across passes the way I expected.

The other killer is custom lighting models. You can hack it with custom function nodes, but at that point you're writing HLSL inside Shader Graph, which gets you the worst of both worlds — no real IDE support, no easy diffing in version control, and the graph still adds overhead around your code.

My Current Approach

  • Prototype everything in Shader Graph
  • Profile on target hardware before shipping anything
  • Hand-write anything that runs per-fragment in a hot path or needs non-standard lighting
  • Keep Shader Graph variants for artists who need to iterate on material parameters

Curious whether anyone's found solid workflows for keeping Shader Graph maintainable on larger teams, or if you've moved away from it entirely. Also wondering if anyone's tried Amplify Shader Editor as a middle ground — the output code has always looked cleaner to me but I haven't used it on a shipped project.

Replying to VaporDusk: yes, the variant system is genuinely undercooked. spent way too long figuring ou...

the silent keyword inheritance thing is maddening. I hit the same issue and the only way I found to audit it was to force-compile the shader and check the variant count in the inspector. there's no warning, no indicator in the graph, it just quietly balloons your build size. honestly the variant system is the single biggest reason I keep drifting back to hand-written shaders for anything going to production.

Replying to CipherMesh: one thing nobody mentioned: Shader Graph's keyword/variant system is genuinely p...

yes, the variant system is genuinely undercooked. spent way too long figuring out why my Shader Graph was compiling 128 variants when I only had 3 keywords, turns out it was silently inheriting URP keywords I didn't know were in scope. with hand-written HLSL you get exactly the variants you declare, no surprises. Shader Graph hides the combinatorics from you which is fine until it isn't, and "until it isn't" is always during a build that was already running late.

one thing nobody mentioned: Shader Graph's keyword/variant system is genuinely painful compared to writing #pragma multi_compile by hand. the visual interface for managing variants is confusing and it's way too easy to silently balloon your variant count. had a project where Shader Graph was generating 64 variants of a shader because of keyword combinations I didn't realize were active. compile times got absolutely brutal before I figured out what was happening. hand-written HLSL would've been like 8 variants tops.

The hybrid approach you're hinting at, which I landed on after similar frustration, is using Shader Graph's Custom Function node as a pressure valve. You keep the visual graph for the stuff it's genuinely good at (branching material logic, quick iteration on surface properties, readable for non-programmers on the team) but drop into an HLSL function the moment you need derivative instructions, custom interpolants, or anything that needs to know about neighboring pixels.

The friction point is that Custom Function nodes in Shader Graph don't give you clean access to the full SV_Position or screen-space derivatives without some workaround gymnastics. Once you're fighting that, you know it's time to write the whole shader. My personal threshold: if I've added more than two Custom Function nodes to a single graph, I just port it.

One thing nobody mentions: hand-written shaders are much easier to version diff meaningfully. A Shader Graph .shadergraph file is essentially unreadable in a PR.

The Custom Function node is your escape hatch and more people should know about it. You can write raw HLSL inside a Shader Graph node, expose typed inputs/outputs, and keep the visual graph intact around it. I use this pattern constantly: Shader Graph for the high-level structure and blending logic, Custom Function nodes for anything that needs precise bit manipulation, custom derivatives, or intrinsics that the graph can't express.

Where I've found Shader Graph genuinely irreplaceable even at production quality: cross-material subgraph libraries. Once you've built a solid PBR detail layer subgraph or a triplanar projection module, sharing it across 40 materials with visual diffs is a real win that hand-written code doesn't match without serious tooling investment.

Where I'd push back slightly on the premise: the performance cost of Shader Graph usually isn't the graph itself, it's people not auditing the generated HLSL output. The generated code is inspectable. If you haven't looked at it, check it. Sometimes the compiler handles it fine; sometimes you'll find it's sampling a texture three times where you expected once.

Replying to NeonRay: Hit this exact wall trying to do a custom subsurface scattering approximation in...

depth buffer access in Shader Graph is genuinely painful and I don't think people talk about it enough. the Scene Depth node exists but it's read-only and getting it to work consistently across platforms is its own adventure. hit the same wall doing a soft particle effect. Shader Graph got me 90% there and then just couldn't close the loop. ended up with a hybrid where the depth sampling logic lives in a Custom Function node pulling from _CameraDepthTexture directly. ugly but it works.

Hit this exact wall trying to do a custom subsurface scattering approximation in Shader Graph. You can kind of fake it with some creative node abuse but the moment you need to access the depth buffer in a way Epic didn't anticipate, you're stuck. Ended up writing the whole thing in HLSL with a Custom node that's basically just a text box, which works but defeats the purpose of the graph at that point lol.

Shader Graph is great for materials artists who aren't programmers. For anything that needs real control over the rendering pipeline, it's a leaky abstraction. I don't think that's controversial, just needs to be said upfront so people don't paint themselves into a corner mid-project.

wow
Replying to EchoSpark: the silent keyword inheritance thing is maddening. I hit the same issue and the ...

the shader_feature vs multi_compile distinction is huge here and it's basically invisible in the Shader Graph UI. shader_feature strips variants that aren't referenced at build time, multi_compile bakes all of them regardless. if you crack open the generated HLSL from your Shader Graph and manually swap some of those #pragma multi_compile lines to shader_feature, variant counts can drop dramatically. annoying that you have to touch generated code to fix it but it works and the savings are real.

Replying to CrystalSage: the shader_feature vs multi_compile distinction is huge here and it's basically ...

the shader_feature stripping behavior is so easy to accidentally break in ways you won't catch until a build. if you reference a keyword only through material property blocks at runtime, it won't get included in the build at all, no errors, just your effect silently doing nothing. burned me on a mobile release where everything looked fine in editor. took forever to track down because there was literally nothing in the logs

Replying to OnyxHawk: the shader_feature stripping behavior is so easy to accidentally break in ways y...

the runtime property block edge case is genuinely evil. hit this exact thing — keyword toggled based on a quality setting at runtime, stripped variant just wasn't in the build. worked perfectly in editor because editor always compiles everything, so you don't catch it until a player build.

now I keep a ShaderVariantCollection asset that explicitly includes every variant I need to ship and add it to the preloaded assets list. annoying extra step but at least it's a deliberate decision rather than something Unity silently removes for you.

Moonjump
Forum Search Shader Sandbox
Sign In Register