Image

Listens: Эдуард Хиль — [Я шагаю по Москве (песни А.Петрова) #13] Память

Categories:

Формула лайтсабера

А мы продолжаем нашу рубрику «опишем матаном всякое» (любовь, мандарины). На этот раз осветим протяженным равномерным источником света плоскость.

Я не хочу матана, я хочу лайтсаберы


Пусть мы смотрим на перпендикулярную направлению взгляда плоскость с нормалью \vec{N} в точку \vec{C}, освещенную лампой с началом в \vec{A} и концом в \vec{B}. Освещенность от точечного источника обратно пропорциональна квадрату расстояния до источника и прямо пропорциональна косинусу направления света (угла между вектором света и нормалью). Тогда освещенность точки \vec{C} равна

lum(\vec{C}, \vec{A}, \vec{B}, \vec{N})  \sim  \int\limits_0^1{ \frac{ \langle \vec{A} t + \vec{B} (1-t) - \vec{C}, \vec{N} \rangle \,dt }{ {\| \vec{A} t + \vec{B} (1-t) - \vec{C} \|}^3 \|\vec{N}\| } }  \sim  \\\sim  \int\limits_0^1{ \frac{ \left( \langle \vec{A}-\vec{B}, \vec{N} \rangle t + \langle \vec{B}-\vec{C}, \vec{N} \rangle \right) \,dt }{ {\left( \langle \vec{A}-\vec{B},\vec{A}-\vec{B} \rangle t^2 + 2 \langle \vec{A}-\vec{B},\vec{B}-\vec{C} \rangle t + \langle \vec{B}-\vec{C},\vec{B}-\vec{C} \rangle \right)}^\frac32 } }

Если обратить внимание, что векторы куда-то исчезли, а остались сплошные скаляры, причем в скобках в знаменателе неотрицательная норма вектора, останется простой интеграл вида

\int\limits_0^1{ \frac{ \left( at + b \right) \,dt }{ {\left( At^2 + 2Bt + C \right)}^\frac32 } } = \Bigl. \frac{ (aB-Ab)t + (aC-bB) }{ \sqrt{ At^2 + 2Bt + C } \left( B^2-AC \right) } \Bigl|_0^1

А теперь реализуем это с помощью WebGL. Подобно рисованию на канвасе WebGL это просто получение у тэга <canvas> хитрого контекста, с которым можно обращаться, как с OpenGL. Сперва напишем HTML, а потом добавим скрипты и под конец шейдеры:

<!DOCTYPE HTML>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>WebGL lamps</title>
</head>
<body onload="webGLStart();">
<canvas id="canvas" width="450" height="450"></canvas>
</body>
</html>

Нам нужно создать контекст WebGL, собрать шейдеры (удобно разместить их код на GLSL в отдельном тэге <script> с type, отличным от text/javascript, чтобы его не пытался исполнить браузер), скормить OpenGL вертексный буфер (хранящий координаты точек полигональной модели, в нашем случае просто квада из двух треугольников, на котором мы будем рисовать пиксельным шейдером) и индексный буфер (хранящий индексы вертексного буфера, образующие геометрию), немного настроить все это и запустить цикл анимации:

function webGLStart()
{
    var gl = null;
    // Квад из двух треугольников
    var model = [-1,-1,0, -1,1,0, 1,1,0, 1,-1,0];
    var ind = [0,1,2,0,2,3];
    var startTime = (new Date()).getTime() / 1000.0;
    var curTime, curFrame = 0, prevTime = 0.0, prevFrame = 0;

    // Получаем канвас, контекст WebGL, устанавливаем вьюпорт
    var canvas = document.getElementById('canvas');
    if (!window.WebGLRenderingContext) {
        alert("You browser does not support WebGL");
        return;
    }
    try {
        gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
    } catch (e) {}
    if (gl === null) {
        alert("Error while WebGL initialization");
        return;
    }
    gl.viewport(0, 0, canvas.width, canvas.height);

    // Создаем программу, аттачим шейдеры, линкуем, используем
    var prog  = gl.createProgram();
    gl.attachShader(prog, getShader(gl, "shader-vs"));
    gl.attachShader(prog, getShader(gl, "shader-fs"));
    gl.linkProgram(prog);
    gl.useProgram(prog);

    // Устанавливаем атрибут вершин - позицию из массива
    var posLocation = gl.getAttribLocation(prog, "aPos");
    gl.enableVertexAttribArray(posLocation);
    var posBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(model), gl.STATIC_DRAW);
    gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 0, 0);

    // Настраиваем индексный буфер
    var indexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(ind), gl.STATIC_DRAW);

    // Включаем тест глубины и устанавливаем цвет очистки
    gl.enable(gl.DEPTH_TEST);
    gl.depthFunc(gl.LEQUAL);
    gl.clearDepth(1.0);
    gl.clearColor(0.1, 0.1, 0.1, 1);

    // Запускаем отрисовку сцены
    (function animloop() {
        drawScene();
        requestAnimFrame(animloop, canvas);
    })();

Дополним это функцией getShader, которая и будет вытаскивать код шейдеров из еще не написанных тэгов <script>:

// Создание и компиляция шейдера
function getShader(gl, id)
{
    var elem = document.getElementById(id);
    var shader = gl.createShader(elem.type == 'x-shader/x-fragment' ? gl.FRAGMENT_SHADER : gl.VERTEX_SHADER);
    gl.shaderSource(shader, elem.innerHTML);
    gl.compileShader(shader);
    if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) == 0) {
        alert(gl.getShaderInfoLog(shader));
    }
    return shader;
}

Функция window.requestAnimationFrame это средство, предоставляемое браузером для JavaScript-программ, чтобы отрисовывать все кадры разом, синхронизируя их отрисовку с обновлением монитора. Она позволяет, в частности, не жрать процессорное время на отрисовку кадров, которые все равно не успеют отобразиться. Здесь она несколько окроссплатформизирована:

// Пользуемся средствами браузера для более плавной анимации
window.requestAnimFrame = (function() {
    return window.requestAnimationFrame ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame ||
        window.oRequestAnimationFrame ||
        window.msRequestAnimationFrame ||
        function(callback, element) {
            window.setTimeout(callback, 1000 / 60);
        };
})();

В самой функции отрисовки геометрии посчитаем FPS, передадим текущее время в шейдеры и отрисуем два наших треугольника:

function drawScene()
{
    // Получаем и передаем шейдеру время, показываем FPS
    curTime = (new Date()).getTime() / 1000.0 - startTime;
    ++curFrame;
    if (curTime - prevTime > 1.0) {
        var fps = (curFrame - prevFrame) / (curTime - prevTime);
        prevFrame = curFrame;
        prevTime = curTime;
        window.document.title = "WebGL lamps: " + Math.round(fps * 100) / 100 + " fps";
    }
    gl.uniform1f(gl.getUniformLocation(prog, "time"), curTime);
   
    // Рисуем
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
    gl.flush();
}

Теперь несколько слов поподробнее о шейдерах. Шейдер это программа, которая (после компиляции) передается на видеокарту, где заменяет собой какой-то этап графического конвейера. Изначально было всего два типа графических шейдеров (скажем, в DirectX 9, который до сих пор все используют, потому что консоли отстой): вертексные и пиксельные (в дурацкой терминологии OpenGL фрагментные). Вертексные шейдеры позволяют что-нибудь сделать с вертексным буфером, в нашем случае просто передать координаты дальше на отрисовку:

<script id="shader-vs" type="x-shader/x-vertex">
attribute vec3 aPos;
varying vec4 pos;

void main(void) {
    gl_Position = vec4(aPos, 1.);
    pos = vec4(aPos, 1.0);
}
</script>

Стоит вкратце остановиться на механизме передачи параметров. Пиксельному шейдеру нужна координата того пикселя, который он сейчас будет отрисовывать и текущее время для анимации. В этих строках

var posLocation = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(posLocation);
gl.vertexAttribPointer(posLocation, 3, gl.FLOAT, false, 0, 0);

мы указали, что хотим передавать в параметр вертексного шейдера aPos тройки из нашего вертексного буфера. Сам вертексный шейдер тупо копирует координаты точек в varying-параметр pos пиксельного шейдера. Слово varying означает, что для каждого треугольника видеокарта будет интерполировать вершинные значения параметра для каждого принадлежащего ему на экране пикселя. Учитывая, что интерполирует она линейно, как раз и получатся верные координаты пикселей. Время же мы передаем в uniform-параметре в каждом кадре непосредственно:

gl.uniform1f(gl.getUniformLocation(prog, "time"), curTime);

Перейдем же к самому интересному — пиксельному шейдеру. Вот его заголовок с параметрами:

<script id="shader-fs" type="x-shader/x-fragment">
#ifdef GL_ES
precision highp float;
#endif
varying vec4 pos;
uniform float time;

В коде главной функции пиксельного шейдера сгенерируем начальные координаты «звездочки» из лайтсаберов, заставим концы этих лайтсаберов двигаться с постоянной скоростью, отражаясь от краев вьюпорта, и посчитаем вызовом функции newosv, которая будет чуть ниже, яркость освещения текущего пикселя всеми этими лайтсаберами. В завершении наведем красоту, добавив изменение цвета лайтсаберов по радуге.

const float pi = 3.1415926;

void main(void) {
    vec4 color;
    vec3 p = pos.xyz;
    float zdist = 0.2;

    // Звездочка
    const int n = 10;
    const int conn = 2;
    vec3 b[n], a[n];
    for (int i = 0; i < n; i += 2) {
        float phi0 = float(i) * 2.0 * pi / float(n) + pi / 2.0;
        float phi2 = float(i + conn * 2) * 2.0 * pi / float(n) + pi / 2.0;
        float r = 0.9;
        b[i  ] = vec3(r * cos(phi0), r * sin(phi0), zdist);
        b[i+1] = vec3(r * cos(phi2), r * sin(phi2), zdist);
    }

    // Скорость
    vec3 v = vec3(pi, sqrt(5.0), 1.0) / 10.0;
    // Вектор нормали к плоскости
    vec3 N = vec3(0.0, 0.0, 1.0);
   
    // Считаем координаты концов отрезков после отражений
    for (int i = 0; i < n; ++i) {
        a[i] = modreflect3d(b[i] + v * time, -1.0, 1.0, -1.0, 1.0, 0.1, 0.2);
    }
   
    // Считаем освещение
    float lum = 0.0;
    for (int i = 0; i < n; i += 2) {
        lum += newosv(p, N, a[i], a[i + 1]);
    }
    lum /= float(n) / 2.0;

    // Расчет "радужного" цвета
    gl_FragColor = vec4(
        lum * (sin(time) + 1.0) / 2.0,
        lum * (sin(time + pi / 3.0) + 1.0) / 2.0,
        lum * (sin(time + 2.0 * pi / 3.0) + 1.0) / 2.0,
        1.0);
}

Но сперва функции для отражения:

// Отражение по одной координате
float modreflect1d(float a, float ma, float mb)
{
    float pos = mod(a - ma, 2.0 * (mb - ma));
    return min(pos, 2.0 * (mb - ma) - pos) + ma;
}

// Отражение вектора по трем координатам
vec3 modreflect3d(vec3 a, float minx, float maxx, float miny, float maxy, float minz, float maxz)
{
    return vec3(modreflect1d(a.x, minx, maxx), modreflect1d(a.y, miny, maxy), modreflect1d(a.z, minz, maxz));
}

И, наконец, само освещение с отдельной функцией для определенного интеграла. Обратите внимание, что освещение в конце умножается на длину лайтсабера, чтобы они не тускнели, удлиняясь.

// int((a*x+b) / sqrt(A*x^2+B*x+C)^3, x=0..1)
float integral(float a, float b, float A, float B, float C)
{
    return 2.0 * ((a * (B+C+C) - b * (A+A+B)) / sqrt(A + B + C) - (2.0 * a * C - b * B) / sqrt(C)) / (B * B - 4.0 * A * C);
}

// Освещение точки P с нормалью N линейным источником AB, нормированное длиной источника
float newosv(vec3 P, vec3 N, vec3 A, vec3 B)
{
    vec3 V = B - A;
    vec3 W = A - P;
    float a = dot(N,V);
    float b = dot(N,W);
    float c = dot(V,V);
    float d = 2.0 * dot(V,W);
    float e = dot(W,W);
    float result = integral(a, b, c, d, e);
    float diffuse = result < 0.0 ? 0.0 : result;   
    return diffuse * distance(A, B) / 4.0;
}



Если у вас Firefox и он отказывается говорить и показывать, попробуйте перейти на about:config и сменить webgl.force-enabled на true. Впрочем, если очень старая видеокарта, это не поможет. Тогда смотрите на картинку под катом:


Image


Если вас заинтересовал WebGL, загляните на Chrome Experiments.

UPD: В два раза больше лайтсаберов — вдвое красивее (только для хороших видеокарт)