Сфера в трехмерной графике обычно состоит из сотни-другой треугольников, при этом половина из них не видна человеку, поскольку их отсекает face culling и/или zbuffer.
Сфера всегда кажется наблюдателю кругом. Поэтому можно схитрить и вместо сферы нарисовать плоскость, которая всегда повернута к наблюдателю одной стороной. А уже на этой плоскости с помощью математики и какой-то матери изобразить текстуру, освещение и блик.
Нам нужно вычислить три главных параметра: позиция плоскости, её размер и трехмерные координаты сферы, спроецированной на эту плоскость. Получив эти параметры можно приступить к обману нашего наблюдателя.
С позицией плоскости все относительно просто: если вы рисуете ближнюю часть сферы, то это сдвиг на радиус от центра воображаемой сферы к камере.
Вычисление размера плоскости
Размер плоскости будем вычислять используя школьную геометрию, в частности, уроки про теорему Пифагора, касательные к окружности и определения подобных треугольников. Итак, взгляните на эти картинки (наблюдатель слева, сфера справа, вид сбоку, синим обозначена гипотенуза):
Поскольку 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) — расстояние от камеры до ближней точки сферы ($D — R$)
- дальняя плоскость отсечения (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();