1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
|
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Creating a Blacklight Shader - soaos</title>
</head>
<body>
<a href="/">Go Home</a>
<a href="..">Go Back</a>
<h1>Creating a Blacklight Shader</h1>
<font color="red">
<b>NOTE: THIS POST WAS TRANSFERRED FROM MARKDOWN BY HAND SO I MIGHT HAVE MISSED SOME STUFF SORRY</b>
</font>
<p>today i wanted to take a bit of time to write about a shader i implemented for my in-progress game project (more
on that soon™)</p>
<p>i wanted to create a "blacklight" effect, where specific lights could reveal part of the base texture. this
shader works with <b>spot lights</b> only, but could be extended to work with point lights</p>
<figure>
<img src="blacklight.png" alt="Example of shader running, showing hidden writing on a wall" width="100%">
<figcaption>Example of shader running, showing hidden writing on a wall.</figcaption>
</figure>
<p>i wrote this shader in wgsl for a <a href="https://bevyengine.org" target="_blank">bevy engine</a> project, but
it should translate easily to other shading languages</p>
<p>the finished shader can be found as part of <a href="https://git.soaos.dev/soaos/bevy_blacklight_material"
target="_blank">this repo</a></p>
<h2>shader inputs</h2>
<p>
for this shader, i wanted the following features:
<ul>
<li>
the number of lights should be dynamic
</li>
<li>
the revealed portion of the object should match the area illuminated by each light
</li>
<li>
the falloff of the light over distance should match the fading of the object
</li>
</ul>
for this to work i need the following information about each light:
<ul>
<li>
position (world space)
</li>
<li>
direction (world space)
</li>
<li>
range
</li>
<li>
inner and outer angle
</li>
<li>
these will control the falloff of the light at its edges
</li>
<li>
outer angle should be less than pi/2 radians
</li>
<li>
inner angle should be less than the outer angle
</li>
</ul>
i also need some info from the vertex shader:
<ul>
<li>
position (<b>world space!</b>)
</li>
<li>
uv
</li>
</ul>
</p>
<p>bevy's default pbr vertex shader provides this information, but as long as you can get this info into your
fragment
shader you should be good to go</p>
<p>lastly i'll take a base color texture and a sampler</p>
<p>
with all of that, i can start off the shader by setting up the inputs and fragment entry point:
<pre>
#import bevy_pbr::forward_io::VertexOutput;
struct BlackLight {
position: vec3<f32>,
direction: vec3<f32>,
range: f32,
inner_angle: f32,
outer_angle: f32,
}
@group(2) @binding(0) var<storage> lights: array<BlackLight>;
@group(2) @binding(1) var base_texture: texture_2d<f32>;
@group(2) @binding(2) var base_sampler: sampler;
@fragment
fn fragment(
in: VertexOutput,
) -> @location(0) vec4<f32> {
}
</pre>
(bevy uses group 2 for custom shader bindings)
</p>
<p>
since the number of lights is dynamic, i use a <a
href="https://google.github.io/tour-of-wgsl/types/arrays/runtime-sized-arrays/">storage buffer</a> to store
that information
</p>
<h2>shader calculations</h2>
<p>the first thing we'll need to know is how close to looking at the fragment the light source
is</p>
<p>
we can get this information using some interesting math:
<pre>
let light = lights[0];
let light_to_fragment_direction = normalize(in.world_position.xyz - light.position);
let light_to_fragment_angle = acos(dot(light.direction, light_to_fragment_direction));
</pre>
the first step of this is taking the dot product of light direction and the direction from
the light to the fragment
</p>
<p>since both direction vectors are normalized, the dot product will be between -1.0 and 1.0</p>
<p>
the dot product of two unit vectors is the cosine of the angle between them (<a
href="https://math.libretexts.org/Bookshelves/Calculus/Calculus_(OpenStax)/12%3A_Vectors_in_Space/12.03%3A_The_Dot_Product#Evaluating_a_Dot_Product">proof
here</a>)
</p>
<p>
therefore, we take the arccosine of that dot product to get the angle between the light and
the fragment
</p>
<p>
once we have this angle we can plug it in to a falloff based on the angle properties of the
light:
<pre>
let angle_inner_factor = light.inner_angle/light.outer_angle;
let angle_factor = linear_falloff_radius(light_to_fragment_angle / light.outer_angle, angle_inner_factor);
</pre>
<pre>
fn linear_falloff_radius(factor: f32, radius: f32) -> f32 {
if factor < radius { return 1.0; } else {
return 1.0 - (factor - radius) / (1.0 - radius);
}
}
</pre>
</p>
<p>
next, we need to make sure the effect falls off properly over distance we can do this by getting the distance
from the light to
the fragment and normalizing it with the range of the light before plugging that into an inverse square falloff
we'll use squared distance to avoid expensive and unnecessary square root operations:
<pre>
let light_distance_squared=distance_squared(in.world_position.xyz, light.position);
let distance_factor=inverse_falloff_radius(saturate(light_distance_squared / (light.range * light.range)), 0.5);
</pre>
<pre>
fn distance_squared(a: vec3f, b: vec3f) -> f32 {
let vec = a - b;
return dot(vec, vec);
}
fn inverse_falloff(factor: f32) -> f32 {
return pow(1.0 - factor, 2.0);
}
fn inverse_falloff_radius(factor: f32, radius: f32) -> f32 {
if factor < radius { return 1.0; } else {
return inverse_falloff((factor - radius) / (1.0 - radius));
}
}
</pre>
</p>
<p>
now we'll have a float multiplier between 0.0 and 1.0 for our angle and distance to the light we can get the
resulting color by multiplying these with the base color texture:
<pre>
let base_color = textureSample(base_texture, base_sampler, in.uv);
let final_color=base_color * angle_factor * distance_factor;
</pre>
this works for one light, but we need to refactor it to loop over all the provided blacklights:
<pre>
@fragment fn fragment( in: VertexOutput ) -> @location(0) vec4<f32> {
let base_color = textureSample(base_texture, base_sampler, in.uv);
var final_color = vec4f(0.0, 0.0, 0.0, 0.0);
for (var i = u32(0); i < arrayLength(&lights); i = i+1) {
let light=lights[i];
let light_to_fragment_direction = normalize(in.world_position.xyz - light.position);
let light_to_fragment_angle = acos(dot(light.direction, light_to_fragment_direction));
let angle_inner_factor = light.inner_angle / light.outer_angle;
let angle_factor = linear_falloff_radius(light_to_fragment_angle / light.outer_angle, angle_inner_factor);
let light_distance_squared = distance_squared(in.world_position.xyz, light.position);
let distance_factor = inverse_falloff_radius(saturate(light_distance_squared / (light.range * light.range)), 0.5);
final_color = saturate(final_color + base_color * angle_factor * distance_factor);
}
return final_color;
}
</pre>
and with that, the shader is pretty much complete you can view the full completed shader code <a
href="https://github.com/soaosdev/bevy_blacklight_material/blob/master/assets/shaders/blacklight_material.wgsl">here</a>
</p>
<p>have fun!</p>
</body>
</html>
|