So, I know I already posted about this in the screenshots thread, but I guess, in the name of “actually talking about game development” like we’ve been discussing in the Quick Questions thread, here is a description of how this shader actually works! ^^ I think this is a fun little technique and it has broad applicability outside of just Godot (it’s just a general thing you could do with a shader, like you could easily adapt this shader to use in OpenGL or Vulkan and I may at some point—I did it in Godot because it’s partially for a freelance Godot scene project Lily and I are doing.) Here’s the demo video yet again:
How to use this shader in Godot 4
Basically, if you have a MeshInstance3D
node selected, you should see a panel with Surface Material Override
on the right side. If you pull that down and click on the material preview, then use the Shader
parameter to select the rennet.gdshader
file, you should be able to then set the parameters, like so:
Tex
in the Shader Parameters
is for the texture you want on the model—it will use the model’s UVs etc.
How the shader works
Here’s the code:
// Copyright 2024 Zoë Sparks (spinnylights)
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the “Software”), to deal in the Software without
// restriction, including without limitation the rights to use, copy,
// modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
/// rennet is a shader that causes the colors in a texture to
/// separate, so to speak. The basic idea is that you pick "center,"
/// "top," and "bottom" / colors, and then colors in the texture close
/// to the "center" color are pushed in the direction of either the
/// "top" or "bottom" color depending roughly on how bright the
/// input color is. Four one-dimensional floating-point parameters
/// give you precise control over this behavior.
shader_type spatial;
/// tex
///
/// The texture you want to put on the model, from which the source
/// colors come.
uniform sampler2D tex : source_color;
/// center
///
/// Colors that are close to this color will be "curdled." You can use
/// the eyedropper tool on a spot in the texture you want to highlight
/// to ensure that that specific area is affected.
uniform vec3 center : source_color;
/// top
///
/// If the average value of the color's (normalized) components is
/// greater than or equal to `col_flip`, this will be mixed into the
/// destination / color.
uniform vec3 top : source_color = vec3(0.1, 0.5, 0.8);
/// bottom
///
/// If the average value of the color's (normalized) components is
/// less than `col_flip`, this will be mixed into the destination
/// color.
uniform vec3 bottom : source_color = vec3(0.9, 0.5, 0.2);
/// col_flip
///
/// If the average of the source color's components is greater than or
/// equal to this, `top` will be mixed into the destination color,
/// while if it / is lower, `bottom` will be instead.
uniform float col_flip = 0.5;
/// min_dist
///
/// The distance between the source color vector and the `top` or
/// `bottom` vector must be less than this for the curdling to take
/// place there.
uniform float min_dist = 0.2;
/// scale
///
/// Increasing this pushes the result more towards the destination
/// color.
uniform float scale = 5.0;
/// mixy
///
/// This parameter allows you to have the destination color be
/// influenced more or less greatly by the source color. It controls
/// how much of a more-saturated version of the source color is mixed
/// into the destination color. At 1.0, you get only the `top` or
/// `bottom` color as the destination; at 0.0, you get only the
/// saturated version of the source color.
uniform float mixy = 0.5;
float avg(vec3 v) {
return (v.r + v.g + v.b) / 3.0;
}
void fragment() {
vec3 tex_col = texture(tex, UV).rgb;
float col_dist = distance(tex_col, center);
if (col_dist < min_dist) {
vec3 dest_vec = top;
if (avg(tex_col) < col_flip) {
dest_vec = bottom;
}
vec3 mixed_col = mix(normalize(tex_col), dest_vec, mixy);
float dist = col_dist*scale*10.0 / min_dist;
tex_col = mix(tex_col, mixed_col, dist);
}
ALBEDO = tex_col;
}
This shader is the basis of all the animations in that video, including on the title cards. I kind of happened on the idea for it trying to think up a way to make that manta ray model do something bioluminscent, just because I thought that would be a cool thing to do as part of its animation basically.
So, anyway, we’ve basically got, like, the texel from the input texture for the current fragment:
// ...
uniform sampler2D tex : source_color;
//...
void fragment() {
vec3 tex_col = texture(tex, UV).rgb;
// ...
}
We can think of tex_col
as a “color vector,” akin to a vector {r, g, b} in R³ with components that range between 0.0 and 1.0. We can denote this vector as Ct. The user has also picked an arbitrary color vector, center
or Cc:
// ...
uniform vec3 center : source_color;
// ...
We first compute the distance between Ct and Cc:
void fragment() {
// ...
float col_dist = distance(tex_col, center);
// ...
}
Representing col_dist
as d:
This measures how “far apart” the texel color is from the user’s chosen center color. (I’m using a “dot notation” for the vector components here akin to what GLSL does or w/e—hopefully the meaning is obvious.)
The user has also set a parameter min_dist
:
// ...
uniform float min_dist = 0.2;
The minimum distance between color vectors is of course 0; the maximum distance, between black and white, is √3 ≅ 1.73. I picked the default value of 0.2 just by feel; in practice the maximum distance that will have an effect depends on the center color (for example the distance between {0.0, 0.0, 0.0} (black) and {0.5, 0.5, 0.5} (mid-grey) is √[3(0.5)²] ≅ 0.87).
Anyway, we only proceed if the distance between the texel color and the center color is less than min_dist
—otherwise we pass through the texel color unchanged:
void fragment() {
vec3 tex_col = texture(tex, UV).rgb;
float col_dist = distance(tex_col, center);
if (col_dist < min_dist) {
// ...
}
ALBEDO = tex_col;
}
So, the remainder of the fragment shader is the actual color processing, which only occurs if the texel color is within min_dist
of the center color.
The actual processing makes use of this little utility function:
float avg(vec3 v) {
return (v.r + v.g + v.b) / 3.0;
}
This is kind of a rough proxy for the color’s brightness. In practice it might have made more sense to do the calculation based on relative luminance…but I didn’t feel like it. I felt like this would be fine and indeed I liked the results. This way does have the advantage that if col_flip
is 0.5
, the possible values for dest_vec
will be evenly-split between top
and bottom
, which gives lots of room for visual variety.
Anyway, that leads to this:
void fragment() {
// ...
if (col_dist < min_dist) {
vec3 dest_vec = top;
if (avg(tex_col) < col_flip) {
dest_vec = bottom;
}
// ...
col_flip
is another parameter set by the user, as are the colors top
and bottom
:
// ...
uniform vec3 top : source_color = vec3(0.1, 0.5, 0.8);
// ...
uniform vec3 bottom : source_color = vec3(0.9, 0.5, 0.2);
// ...
uniform float col_flip = 0.5;
The rough effect of this is that for brighter colors, dest_vec
will be top
, whereas for darker colors, it will be bottom
.
Now we perform the actual color mixing.
// ...
vec3 mixed_col = mix(normalize(tex_col), dest_vec, mixy);
float dist = col_dist*scale*10.0 / min_dist;
tex_col = mix(tex_col, mixed_col, dist);
}
First, we find mixed_col
by linearly-interpolating between a normalized (more bright and saturated) version of the texel color and whatever dest_vec
ended up being (top
or bottom
). The normalization is just to make the effect pop more, and to emphasize the impact of the texel color on the result (this makes the results less homogeneous, as long as the texture is busy).
The degree of interpolation used to find mixed_col
is set by the user in the parameter mixy
:
// ...
uniform float mixy = 0.5;
At 1.0, only dest_vec
is used, with no reference to the texel color; at 0.0, only the more-saturated texel color is used, with no reference to dest_vec
.
Then, to find the ultimate output color for the fragment, we linearly-interpolate between the texel color and mixed_col
. To find the degree of interpolation, we basically start with col_dist / min_dist
; col_dist
is always less than min_dist
and greater than 0 in this part of the code, and min_dist
is…probably greater than 0, unless the user wants to try making it negative in which case they can have fun with that…anyway, that implies that col_dist / min_dist
will range between 0 (when the color is exactly at the center, in which case the texel color will be passed through unchanged) and 1.0 (when the distance between the texel color and center is about as far as it can be without reaching min_dist
, in which case the color will be very close to mixed_col
).
So, in short, the effect preserves parts of the original texture that are close to center
, and pushes the further-away colors towards a mixture of top
or bottom
and the original texel color (mixed_col
) depending on how bright/saturated the original color was.
Anyway, the effect becomes more dramatic the more the colors are changed, so the parameter scale
is provided to the user to scale the interpolation towards or away from mixed_col
:
// ...
uniform float scale = 5.0;
This itself is scaled by 10 (because I found that felt better):
// ...
float dist = col_dist*scale*10.0 / min_dist;
tex_col = mix(tex_col, mixed_col, dist);
}
ALBEDO = tex_col;
}
And that’s it!
The title cards
I did all these quickly in Krita, using its stamp-like brushes with low opacity and that sort of thing. I figured as long as they had a lot of “texture” and the text contrasted with the background, applying the shader to them would yield cool and legibile results, so I tried not to fuss over them too much beyond that.
The music
The music started out as a piece of mine from a while back called rat_house.it
. I did that with Schism Tracker. However, for this demo video, I first slowed down the rendered audio of rat_house.it
to 90% of its original speed using sox:
% sox rat_house_full_3_norm.wav rat_house_slow.wav speed 0.9
Then I used this as input to the following Csound program:
<CsoundSynthesizer>
<CsOptions>
-o rat_house_slow_start.wav -W
</CsOptions>
<CsInstruments>
sr = 48000
ksmps = 1
nchnls = 2
0dbfs = 1
instr 1
idur = p3
iramp = 2.0
idec1 = 45.0
idec2 = 10.0
idec3 = 70.0
kpitch init 0
ktime times
iflashtime = 0.25
ifstflash = 46.6
isndflash = 54.6
iendpart = 82.75
iendpartfin = 86.7
ifinish = 104.0
ifinallen = 3.0
iend = 104.67
if (ktime >= ifstflash && ktime < (ifstflash + iflashtime)) then
kpitch = 5
elseif (ktime >= isndflash && ktime < (isndflash + iflashtime)) then
kpitch = 1/(8.2)
elseif (ktime >= iendpart && ktime < iendpartfin) then
kpitch transeg 2, ifinish - iendpart - ifinallen, 5, 4, ifinallen, -5, 1/4
elseif (ktime > iendpartfin + 4) then
kpitch transeg 0.2, iend - iendpartfin - 4, -10, 0.9
elseif (ktime > iendpartfin) then
kpitch transeg 0.3, 2, -20, 0.2
else
kpitch transeg 0.0001, iramp, 5, \
1.5, idec1, 5, \
0.45, idec2, -2, \
3, idec3, -50, \
0.3, idur - iramp - idec1 - idec2 - idec3, -20, \
0.05
endif
if (ktime >= iend - 0.5) then
kpitch -= transeg(0.065, 0.5, -4, 0)
endif
idellenlen = 16.45
idellen = 16.85
arawl, arawr diskin2 "rat_house_slow.wav", kpitch, 0, 1
if (ktime >= iend - idellen) then
; csound does have arrays and looping structures but (a) they're actually
; more verbose in this context, and worse (b) they always seem to be
; buggy for audio signals, at least for me, so we have to put up with a
; bit of not-DRY-ness here
idelta1 = 0.13
idelta2 = 0.17
idelta3 = 0.19
kwet1 oscil 0.2, idellenlen / 7
kwet = oscil(0.5 + kwet1, idellenlen / 8) + 0.5
kdry = 1 - kwet
adell1 delay arawl, idelta1
adelr1 delay arawr, idelta1
adell2 delay arawl, idelta2
adelr2 delay arawr, idelta2
adell3 delay arawl, idelta3
adelr3 delay arawr, idelta3
adell = (adell1 + adell2 + adell3) / 3
adelr = (adelr1 + adelr2 + adelr3) / 3
outs arawl*kdry + adell*kwet, arawr*kdry + adelr*kwet
else
out arawl, arawr
endif
endin
</CsInstruments>
<CsScore>
i1 0 270
</CsScore>
</CsoundSynthesizer>
Most of this program is just an elaborate curve used to control the rate at which diskin2
reads in rat_house_slow.wav
. This is like adjusting the speed with a knob on a tape player. The curve looks like this:
You can see it reflected in the spectrogram:
(EDIT: Fixed the spectrogram—I had the wrong one earlier.)
The rest of the program only comes on in the latter “rockabilly” section, and gets the effect in that section by having the main signal and a series of slightly-delayed versions of it crossfade in and out with each other.
Anyway, after rendering the audio from this, I applied sox
’s default reverb to it just for kicks:
% sox rat_house_slow_start.wav rat_house_slow_start_verb.wav reverb
And that was the whole deal!