Devlog #10 - Shaders and the Fine Art of Making the GPU Little Less Annoyed
<br />
Honest status first
This is a devlog about an uncomfortable little truth:
Making shaders prettier is easy.
Making shaders prettier while they still compile everywhere, remain moddable,
fit inside a survivors-like screen full of effects, and do not slowly turn the
frame budget into wet cardboard is the actual work.
The recent shader pass is still in the working tree along with documentation
and version metadata. It has been checked, the shader loader accepted the full
set, and mod_check is happy. But it is not a magic certificate from the GPU
gods. It means the work is coherent enough to inspect, tune, and criticize.
Which is, frankly, the most useful state for shader work anyway.
The trap: “more detail” is not a plan
The user-facing request was clear: the healing ray should feel closer to a
high-end chain heal style effect. Not a lazy green line. Not a stock beam with
a halo and a hopeful name. A support spell with travel, hop intention, impact,
warmth, and the slightly smug visual confidence of something that knows it is
restoring health at exactly the dramatic moment.
The obvious move is to add layers:
-
a hotter core
-
a second strand
-
travel packets
-
forklets around the recipient
-
motes
-
endpoint blooms
-
leap hints
-
rings
-
a little shimmer, because apparently we live here now
And visually, that is the right instinct. The problem is that every one of those
layers wants math. Distance fields, noise, loops, curve samples, sine gates,
segment projections, bloom falloffs. The GPU will absolutely do all of this,
because the GPU is very brave and very literal. It will also do it for every
fragment of every effect quad, at the same time, while the player is moving and
fifty enemies are reconsidering their life choices.
So the job became less “add more stuff” and more:
> Which details actually sell the spell, and which details are just me paying
> rent to the instruction counter?
Deeply glamorous work. Truly, the red carpet of arithmetic.
The first useful rule: make the silhouette earn its cost
Good VFX reads from far away first.
If the only thing a shader adds is texture inside a blob, it may look nicer in
a screenshot and still fail during play. EchoWarrior needs readable chaos, which
means the big shape matters more than the microscopic wiggle.
For the healing ray, the readable shape is now:
-
a live anchored curved stream from caster to target
-
a sister strand so it feels braided, not like a pointer line
-
white-hot packets moving toward the recipient
-
green-gold forklets and bloom at the healed actor
-
short leap hints that imply the effect wants to jump onward
Those are silhouette and motion layers. They communicate “healing chain” before
anyone notices the exact noise function. That is the useful kind of expensive.
The same thinking applies to the other shaders:
-
chainwants a jagged conductor, return arc, and branch lashes -
dartwants a head, spine, speed streaks, and impact bloom -
novawants a shell, pressure front, debris, and afterglow -
voidwants inward pull, counter-motion, horizon, and lensing
If a layer does not help the effect read faster, it has to justify itself very
carefully. Preferably in writing, where it can feel embarrassed.
The second useful rule: shared math is design, not tidiness
Several shaders needed the same little geometric operation: distance from a
fragment to a line segment. Forks, barbs, leap hints, lashes. Same problem, same
math, slightly different local copies.
That is not just duplication. It is future visual drift.
If every shader carries a private version of “distance to a segment,” then the
next pass changes one of them, forgets two of them, and accidentally invents
three dialects of the same spell language. Very artisanal. Very cursed.
So the shared arcane.glsl prelude now includes:
float aw_segment_distance(vec2 p, vec2 a, vec2 b) { /* ... */ }
and for traveling light packets:
float aw_packet_train(float path_t, float progress, float offset, float width, float sharp) { /* ... */ }
That second one is small, but important. Travel pulses are one of those details
that make an effect feel alive. They also invite everyone to write their own
slightly broken fract() expression. Now custom shaders get the good version
by asking for the shared prelude.
This is shader optimisation in the unromantic sense: not only fewer repeated
instructions, but fewer repeated ideas.
The Bezier problem, or, “how many samples is enough?”
The healing ray is curved, so each fragment needs to know how close it is to the
curve. There is no free lunch there. You sample points along the curve, find the
nearest, and build the beam from that distance.
The old version took a fixed set of curve samples. That is straightforward and
pleasantly blunt, like measuring a room by throwing rulers at it.
The refinement changed the search to:
-
take a coarse pass across the whole curve
-
find the best candidate
-
sample again near that candidate
So instead of spending every sample evenly across places the fragment probably
isn’t near, the shader concentrates its second pass where it matters. It is not
a grand algorithmic revelation. It is just the shader equivalent of looking
where your keys actually fell instead of politely inspecting the entire house.
The win is modest but real:
-
the curve remains smooth enough for the spell read
-
the sample count comes down
-
the expensive work is less wasteful
-
the code explains the intent better than a long hand-written sample ladder
There is a lesson in there, unfortunately: the fastest shader is often the one
where you stop doing confident nonsense.
Optimisation did not mean making it boring
This is the part I want to be careful about.
Optimising shaders can turn into a joyless little purge. Remove the motes.
Remove the second strand. Remove the pulse. Remove the bloom. Congratulations,
the frame is cheap and the spell looks like a spreadsheet had a medical event.
That is not the goal.
The goal is to keep the expensive details that players perceive:
-
travel direction
-
hit/recipient emphasis
-
shape identity
-
readable timing
-
colour temperature
-
impact
and reduce the boring cost underneath:
-
duplicate helper math
-
unnecessary repeated
pow()calls -
fixed sample ladders where refinement works
-
local copies of shared operations
-
layers that only show up when paused and zoomed in
The shader should still feel a little extravagant. It just should not be
extravagant in ways no one can see.
Vulkan, because of course the word entered the room
While doing this, we also looked at Vulkan.
The short version: under the current stack, Vulkan is not a switch.
EchoWarrior currently uses Macroquad 0.4.14, which rides on Miniquad 0.4.8.
Miniquad supports OpenGL/GLES/WebGL and Metal on Apple platforms. Its shader
path is GLSL for GL and MSL for Metal. There is no “please be Vulkan now” flag
hiding in the pantry.
So the practical Vulkan route is not “turn it on.” It is:
-
keep Macroquad as the known-good playable runtime
-
define a renderer boundary
-
build a tiny
wgpuprobe -
preserve nearest filtering and pixel-snapped camera behavior
-
port texture upload, sprite batching, render targets, post-processing, text,
egui, and shader materials
- then, and only then, consider replacing the Macroquad path
Which is a lot. Not impossible. Just not a weekend toggle, unless the weekend
is six months long and has strong opinions about WGSL.
What was verified
For the shader refinement pass:
-
cargo run --bin mod_check -
runtime
cargo runsmoke test -
shader loader output showing all 16 shaders loaded
-
cargo fmt --check -
cargo check -
version verification for the documented patch line
The smoke test matters because mod_check can prove the manifest and uniform
contracts are coherent, but the graphics driver gets the final vote on whether
the GLSL is real. Drivers are not known for their interest in our feelings.
The humbling part
Shader optimisation is a special kind of humility.
You start with the heroic idea that you are painting light. Then you spend an
hour moving a fract() expression into a helper because three shaders were each
being annoying in their own bespoke way.
You want the effect to look expensive. The trick is making it look expensive in
the places humans actually see, and cheap in all the places only the GPU has to
suffer through.
That is the work now: keep the magic lush, readable, and moddable, while slowly
teaching the shader set to stop doing the same tiny stupid thing sixteen times.
Next: either tune the pixels with human eyes on the running game, or begin the
renderer-boundary work that would make a future Vulkan path less like replacing
the floor while the house is politely on fire.

Reader notes
No notes yet.
Sign in with GitHub to leave a note.
Continue with GitHub