
В электронной музыке есть интересное направление — музыка для осциллоскопов, которая рисует интересные картинки, если выход аудиокарты подключить к осциллоскопу в режиме 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
