Осциллоскоп на WebGL
В электронной музыке есть интересное направление — музыка для осциллоскопов, которая рисует интересные картинки, если выход аудиокарты подключить к осциллоскопу в режиме XY.
К примеру, Youscope, Oscillofun и Khrậng.
Все красивые видео, генерируемые такой музыкой созданы с помощью записи работы настоящего осциллоскопа на видеокамеру. Когда я поискал в сети эмуляторы осциллоскопов, мне не удалось найти такие, которые рисуют мягкие линии, как в настоящем осциллоскопе.
Это сподвигло меня на создацие своего эмулятора осциллоскопа на WebGL: woscope.
В этом посте я расскажу о том как именно происходит рисование линий осциллоскопа в woscope.
Постановка задачи
Есть стерео аудио файл. Каждый сэмпл интерпретируется как координаты точки на плоскости.
Мы хотим получить линию, которая выглядит как линия на экране осциллоскопа, когда тот подключен в режиме XY.
Я решил, что буду рисовать каждый сегмент линии с использованием прямоугольника, который покрывает область экрана, задеваемую пучком.
Яркость всех сегментов будет собираться с помощью gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
.
Генерация вершин
Для сегмента линии, координаты четырех вершин прямоугольника рассчитываются исходя из начала сегмента, конца сегмента и индекса вершины в прямоугольнике.
Две первых точки находятся ближе к началу сегмента, и две последних — к концу сегмента.
Четные точки смещены «налево» от сегмента, а нечетные — «направо».
Такое преобразование довольно просто написать в vertex shader:
#define EPS 1E-6
uniform float uInvert;
uniform float uSize;
attribute vec2 aStart, aEnd;
attribute float aIdx;
// uvl.xy is used later in fragment shader
varying vec4 uvl;
varying float vLen;
void main () {
float tang;
vec2 current;
// All points in quad contain the same data:
// segment start point and segment end point.
// We determine point position using its index.
float idx = mod(aIdx,4.0);
// `dir` vector is storing the normalized difference
// between end and start
vec2 dir = aEnd-aStart;
uvl.z = length(dir);
if (uvl.z > EPS) {
dir = dir / uvl.z;
} else {
// If the segment is too short, just draw a square
dir = vec2(1.0, 0.0);
}
// norm stores direction normal to the segment difference
vec2 norm = vec2(-dir.y, dir.x);
// `tang` corresponds to shift "forward" or "backward"
if (idx >= 2.0) {
current = aEnd;
tang = 1.0;
uvl.x = -uSize;
} else {
current = aStart;
tang = -1.0;
uvl.x = uvl.z + uSize;
}
// `side` corresponds to shift to the "right" or "left"
float side = (mod(idx, 2.0)-0.5)*2.0;
uvl.y = side * uSize;
uvl.w = floor(aIdx / 4.0 + 0.5);
gl_Position = vec4((current+(tang*dir+norm*side)*uSize)*uInvert,0.0,1.0);
}
Рассчитываем яркость в точке
Зная координаты вершин прямоугольника, нужно рассчитать общую интенсивность от движущегося пучка в точке на прямоугольнике.
В моей модели, интенсивность пучка описана нормальным распределением, что довольно распространено в реальном мире.
Где σ — разброс пучка.
Для того чтобы рассчитать общую интенсивность в точке, я интегрирую интенсивность пучка по времени, когда пучек движется от начала к концу сегмента.
Если использовать систему отсчета в которой начало сегмента имеет координаты (0,0) а конец — (length,0), можно записать distance(t) как:
Теперь,
Поскольку является константой, можно вынести за знак интегрирования:
Немного упростим интеграл, заменив t на u/l:
Интеграл нормального распределения — функция ошибок.
Наконец,
Зная аппроксимацию функции ошибок, несложно записать эту формулу в fragment shader'е
Fragment shader
Параметр uvl
, сгенерированный в vertex shader содержит координаты точки в системе отсчета где начало сегмента имеет координаты (0,0) а конец — (length,0).
Этот параметр будет линейно интерполироваться между вершинами треугольников, что нам и нужно.
#define EPS 1E-6
#define TAU 6.283185307179586
#define TAUR 2.5066282746310002
#define SQRT2 1.4142135623730951
uniform float uSize;
uniform float uIntensity;
precision highp float;
varying vec4 uvl;
float gaussian(float x, float sigma) {
return exp(-(x * x) / (2.0 * sigma * sigma)) / (TAUR * sigma);
}
float erf(float x) {
float s = sign(x), a = abs(x);
x = 1.0 + (0.278393 + (0.230389 + (0.000972 + 0.078108 * a) * a) * a) * a;
x *= x;
return s - s / (x * x);
}
void main (void)
{
float len = uvl.z;
vec2 xy = uvl.xy;
float alpha;
float sigma = uSize/4.0;
if (len < EPS) {
// If the beam segment is too short, just calculate intensity at the position.
alpha = exp(-pow(length(xy),2.0)/(2.0*sigma*sigma))/2.0/sqrt(uSize);
} else {
// Otherwise, use analytical integral for accumulated intensity.
alpha = erf(xy.x/SQRT2/sigma) - erf((xy.x-len)/SQRT2/sigma);
alpha *= exp(-xy.y*xy.y/(2.0*sigma*sigma))/2.0/len*uSize;
}
float afterglow = smoothstep(0.0, 0.33, uvl.w/2048.0);
alpha *= afterglow * uIntensity;
gl_FragColor = vec4(1./32., 1.0, 1./32., alpha);
}
Что можно улучшить
- В этом эмуляторе точка движется по прямой линии в каждом сегменте, что иногда приводит к видимо ломанным линиям, чтобы этого избежать можно использовать интерполяцию sinc, увеличив число семплов в несколько раз
- Насыщение пикселов происходит слишком быстро, этого можно было бы избежать, используя Float-текстуры, но есть проблемы с их поддержкой в WebGL. На текущий момент в луче есть маленькие значение красного и синего цвета, что «переполняет» значение в белые пикселы
- Не учитывается гамма-коррекция монитора
- Нет блума, но он может быть и не нужен, учитывая метод генерации линий
- Сделать нативную программу с этим функционалом?
Итоги
Получился довольно реалистичный эмулятор осциллоскопа на WebGL, и математика сыграла большую роль в создании красивой картинки.
Этот метод можно использовать, для генерации других мягких линий.
Надеюсь, статья оказалась познавательной и интересной для читателя.
Код шейдеров отдается в общественное достояние. Полный код woscope доступен на github