Рисуем сферу с помощью двух треугольников

Сфера в трехмерной графике обычно состоит из сотни-другой треугольников, при этом половина из них не видна человеку, поскольку их отсекает face culling и/или zbuffer.

Сфера всегда кажется наблюдателю кругом. Поэтому можно схитрить и вместо сферы нарисовать плоскость, которая всегда повернута к наблюдателю одной стороной. А уже на этой плоскости с помощью математики и какой-то матери изобразить текстуру, освещение и блик.

Нам нужно вычислить три главных параметра: позиция плоскости, её размер и трехмерные координаты сферы, спроецированной на эту плоскость. Получив эти параметры можно приступить к обману нашего наблюдателя.

С позицией плоскости все относительно просто: если вы рисуете ближнюю часть сферы, то это сдвиг на радиус от центра воображаемой сферы к камере.

Вычисление размера плоскости

Размер плоскости будем вычислять используя школьную геометрию, в частности, уроки про теорему Пифагора, касательные к окружности и определения подобных треугольников. Итак, взгляните на эти картинки (наблюдатель слева, сфера справа, вид сбоку, синим обозначена гипотенуза):

RLDHlCh

Поскольку L — это касательная к окружности, треугольник LDR является прямоугольным, а высота H проведенная из прямого угла делит этот треугольник на два ему подобных. Нас интересует треугольник hCl.

Нам известен радиус нашей сферы R и дистанция до камеры D. Обладая знаниями восьмого класса о теореме Пифагора, найдем катет L прямоугольного треугольника LDR:

L=\sqrt{D^2 - R^2}

Также нам известен катет C треугольника hCl:

C = D - R

Поскольку треугольники подобны, то соотношения сторон у них одинаковые:

\frac{h}{C}=\frac{R}{L}

Значит искомый катет h равен:

h=\frac{R*(D-R)}{L}

Итак, мы получили размер нашей плоскости в зависимости от расстояния до камеры и радиуса нашей сферы — с чем я нас и поздравляю!

Вычисление нормали для сферы спроецированной на плоскость

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

Фактически, наша плоскость представляет собой экран для проецирования сферы. Поэтому построим матрицу перспективной проекции, чтобы знать трехмерные координаты каждой точки на этой сфере относительно камеры. Матрица проекции строится по четырем параметрам:

  • угол обзора (fov) — в нашем случае это угол между L и D умноженный на два, то есть 2.0 * acos(L / D)
  • соотношение сторон экрана (aspect ratio) — наша плоскость квадратная, поэтому единичка
  • ближняя плоскость отсечения (near plane) — расстояние от камеры до ближней точки сферы (DR)
  • дальняя плоскость отсечения (far plane) — расстояние от камеры до дальней точки сферы (D + R)

Напрямую эта матрица проекции нам не подходит, поскольку выполняет операцию приведения координат из пространства камеры в плоскость экрана. Для нашей задачи ее необходимо сперва инвертировать.

Умножив полученную матрицу на любой вектор (X, Y, -1) мы получим точку на ближней к наблюдателю плоскости в пространстве камеры. Затем мы построим луч к этой точке из камеры и найдем ближайшее пересечение луча со сферой. После этого можно найти нормаль, вычислив вектор от центра сферы до полученной точки пересечения.

Лирическое отступление — умножив инвертированную матрицу проекции на вектор (0, 1, -1), мы получим вектор, у которого значение Y будет равно размеру нашей плоскости, то есть h. Но мы ведь не ищем легких путей, правда? (:

Результат и исходники

Тестовая схема сделана при помощи библиотеки three.js. На одной половине экрана рисуется честная сфера, на другой имитация. Попробуйте догадаться без подглядывания в исходники — где истинная сфера, а где ложная. Подсказка: ложная сфера выглядит лучше истинной. Освещение сделано практически один в один с предыдущей заметкой об освещении куба.


Показать исходный код

var sphereVS = [
    "varying vec3 v_Normal;",
 
    "void main() {",
        "v_Normal = normal;",
        "gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
    "}"
].join("\n");
 
var sphereFS = [
    "precision highp float;",
 
    "uniform vec3 u_lightDir;",
 
    "varying vec3 v_Normal;",
 
    "void main() {",
        "vec3 n = normalize(v_Normal);",
        "vec3 l = normalize(u_lightDir);",
 
        "float lambertian = max(0.0, dot(n, l));",
        "float specular = 0.0;",
 
        "if (lambertian > 0.0) {",
            "vec3 v = vec3(0, 0, 1);",
            "vec3 r = reflect(-l, n);",
            "float specAngle = max(0.0, dot(r, v));",
            "specular = pow(specAngle, 30.0);",
        "}",
 
        "vec3 diffuseColor = vec3(1.0, 0.0, 0.0) * lambertian;",
        "vec3 specularColor = vec3(0.0, 1.0, 0.0) * specular;",
        "vec3 color = diffuseColor + specularColor;",
 
        "gl_FragColor = vec4(color, 1.0);",
    "}",
].join("\n");
 
var planeVS = [
    "uniform mat4 u_invProj;",
 
    "varying vec2 v_TexCoord;",
    "varying vec3 v_Projected;",
 
    "void main() {",
        "v_TexCoord = uv;",
 
        "vec4 projected = u_invProj * vec4(uv * 2.0 - 1.0, 0.0, 1.0);",
        "v_Projected = projected.xyz / projected.w;",
        "gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
    "}",
].join("\n");
 
var planeFS = [
    "precision highp float;",
 
    "uniform vec3 u_lightDir;",
    "uniform float u_distToCamera;",
    "uniform float u_radius;",
 
    "varying vec2 v_TexCoord;",
    "varying vec3 v_Projected;",
 
    "vec3 getNormal(vec3 projected) {",
        // Позиция камеры в пространстве камеры
        "vec3 p0 = vec3(0, 0, 0);",
        // Направление от плоскости к камере
        "vec3 v = normalize(p0 - projected);",
        // Позиция сферы в пространстве камеры
        "vec3 o = vec3(0, 0, -u_distToCamera);",
        "float tca = dot(o, v);",
        "float sqD = dot(o, o) - tca * tca;",
        "float r = u_radius;",
        "float thc = sqrt(r * r - sqD);",
        "vec3 p = p0 + (tca + thc) * v;",
        "return normalize(p - o);",
    "}",
 
    "void main() {",
        "vec3 n = getNormal(v_Projected.xyz);",
        "vec3 l = normalize(u_lightDir);",
 
        "float lambertian = max(0.0, dot(n, l));",
        "float specular = 0.0;",
 
        "if (lambertian > 0.0) {",
            "vec3 v = vec3(0, 0, 1);",
            "vec3 r = reflect(-l, n);",
            "float specAngle = max(0.0, dot(r, v));",
            "specular = pow(specAngle, 30.0);",
        "}",
 
        "vec3 diffuseColor = vec3(1.0, 0.0, 0.0) * lambertian;",
        "vec3 specularColor = vec3(0.0, 1.0, 0.0) * specular;",
        "vec3 color = diffuseColor + specularColor;",
 
        // Небольшой хак для сглаживания краев нашей сферы
        "float edge = 0.995;",
        "vec2 coords = v_TexCoord * 2.0 - 1.0;",
        "float t = length(coords);",
        "float a = clamp((t - edge) / (1.0 - edge), 0.0, 1.0);",
        "vec4 c4 = mix(vec4(color, 1.0), vec4(color, 0.0), a);",
 
        "gl_FragColor = vec4(c4);",
    "}",
].join("\n");
 
 
var RADIUS = 5.0;
var CAMDIST = 15.0;
 
var planeScene;
var planeUniforms;
 
var sphereScene;
var sphereUniforms;
 
var camera;
var renderer;
 
var canvas;
var canvasWidth;
var canvasHeight;
 
function createPlaneScene(camera) {
    var scene = new THREE.Scene();
 
    var D = CAMDIST;
    var R = RADIUS;
 
    var L = Math.sqrt(D * D - R * R);
    var h = R * (D - R) / L;
 
    var fov = Math.acos(L / D) * 2.0;
 
    var projMatrix = new THREE.Matrix4();
    projMatrix.makePerspective(fov * THREE.Math.RAD2DEG, 1.0, D - R, D + R);
 
    var invProjMatrix = projMatrix.getInverse(projMatrix);
 
    planeUniforms = {
        "u_lightDir" : {
            value : new THREE.Vector3(0, 0, 1)
        },
        "u_invProj" : {
            value : invProjMatrix
        },
        "u_distToCamera" : {
            value : D
        },
        "u_radius" : {
            value : R
        },
    };
 
    var material = new THREE.ShaderMaterial({
        vertexShader : planeVS,
        fragmentShader : planeFS,
        uniforms : planeUniforms,
        transparent : true,
    });
 
    var geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
    var plane = new THREE.Mesh(geometry, material);
 
    plane.position.z = R;
    plane.scale.x = h * 2;
    plane.scale.y = h * 2;
 
    scene.add(plane);
    return scene;
}
 
 
function createSphereScene() {
    var scene = new THREE.Scene();
 
    sphereUniforms = {
        "u_lightDir" : {
            value : new THREE.Vector3(0, 0, 1)
        },
    };
 
    var material = new THREE.ShaderMaterial({
        vertexShader : sphereVS,
        fragmentShader : sphereFS,
        uniforms : sphereUniforms
    });
 
    var geometry = new THREE.SphereGeometry(RADIUS, 32, 32);
    var sphere = new THREE.Mesh(geometry, material);
    scene.add(sphere);
 
    return scene;
}
 
 
function onResize() {
    canvasWidth = canvas.width;
    canvasHeight = canvas.height;
 
    renderer.setSize(canvasWidth, canvasHeight, false);
 
    camera.aspect = canvasWidth * 0.5 / canvasHeight;
    camera.updateProjectionMatrix();
}
 
 
function init() {
    canvas = document.getElementById("webgl-canvas");
 
    canvasWidth = canvas.width;
    canvasHeight = canvas.height;
 
    camera = new THREE.PerspectiveCamera(
        45.0, 
        canvasWidth * 0.5 / canvasHeight,
        1.0, 
        50.0);
 
    camera.position.z = CAMDIST;
 
    sphereScene = createSphereScene();
    planeScene = createPlaneScene();
 
    renderer = new THREE.WebGLRenderer( { antialias: true, canvas : canvas } );
    renderer.setSize( canvasWidth, canvasHeight, false );
    renderer.setClearColor(0x000000);
    renderer.setScissorTest(true);
 
    canvas.addEventListener( 'resize', onResize, false );
}
 
 
function render() {
    requestAnimationFrame( render );
 
    var w = canvasWidth;
    var hw = Math.floor(0.5 * w);
    var h = canvasHeight;
 
    renderer.setViewport(0, 0, hw, h);
    renderer.setScissor(0, 0, hw, h);
    renderer.render(sphereScene, camera);
 
    renderer.setViewport(hw, 0, hw, h);
    renderer.setScissor(hw, 0, hw, h);
    renderer.render(planeScene, camera);
 
    var rotation = (Date.now() / 50) % 360.0;
 
    var x = 0;
    var y = 1;
    var z = 1;
 
    var a = rotation * THREE.Math.DEG2RAD;
    var c = Math.cos(a);
    var s = Math.sin(a);
 
    var lightDir = new THREE.Vector3(
        x * c - z * s,
        y,
        z * c + x * s);
 
    planeUniforms.u_lightDir.value = lightDir;
    sphereUniforms.u_lightDir.value = lightDir;
}
 
init();
render();

Комментариев нет

Добавить комментарий

Ваш e-mail не будет опубликован.