Когда вы визуализируете 3D-объект, вам всегда нужно добавлять к нему какой-то материал, чтобы он был виден, и выглядел так, как вы хотите; неважно, делаете вы это в специальных программах или в режиме реального времени через WebGL.


Большую часть материалов можно имитировать с помощью встроенных средств библиотек вроде Three.js, но в этом уроке я покажу вам, как заставить объекты выглядеть, как стекло за три шага с помощью — вы угадали — Three.js.


Шаг 1: Настройка и фронтальные отражения


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


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


Вопреки вашим ожиданиям, наш материал не будет прозрачным, по сути мы будем искажать то, что будет находиться за алмазом. Для этого нам нужно будет визуализировать сцену (без алмаза) в текстуру. Я просто визуализирую плоскость размером со всю область видимости с помощью ортогональной камеры, но можно так же визуализировать и сцену с другими объектами. Самый простой способ отделить фоновую поверхность от алмаза в Three.js — это использовать Layers.


this.orthoCamera = new THREE.OrthographicCamera( width / - 2,width / 2, height / 2, height / - 2, 1, 1000 );
// добавим камеру к 1 слою (0 слой используется по умолчанию)
this.orthoCamera.layers.set(1);

const tex = await loadTexture('texture.jpg');
this.quad = new THREE.Mesh(new THREE.PlaneBufferGeometry(), new THREE.MeshBasicMaterial({map: tex}));

this.quad.scale.set(width, height, 1);
// поверхность так же добавляем к 1 слою
this.quad.layers.set(1);
this.scene.add(this.quad);

Наш цикл визуализации будет выглядеть так:


this.envFBO = new THREE.WebGLRenderTarget(width, height);

this.renderer.autoClear = false;

render() {
    requestAnimationFrame( this.render );

    this.renderer.clear();

    // визуализируем фон в fbo
    this.renderer.setRenderTarget(this.envFbo);
    this.renderer.render( this.scene, this.orthoCamera );

    // визуализируем фон в области видимости
    this.renderer.setRenderTarget(null);
    this.renderer.render( this.scene, this.orthoCamera );
    this.renderer.clearDepth();

    // визуализируем геометрию в области видимости
    this.renderer.render( this.scene, this.camera );
};

Отлично, время для небольшого экскурса в теорию. Прозрачные материалы вроде стекла видны, потому что преломляют свет. Так происходит потому, что свет через стекло проходит медленней, чем через воздух, а когда световой пучок сталкивается с таким объектом под углом, разница в скорости заставляет свет изменить направление. Это изменение в направлении — то, что понимается под преломлением.



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


varying vec3 eyeVector;
varying vec3 worldNormal;

void main() {
    vec4 worldPosition = modelMatrix * vec4( position, 1.0);
    eyeVector = normalize(worldPos.xyz - cameraPosition);
    worldNormal = normalize( modelViewMatrix * vec4(normal, 0.0)).xyz;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Во фрагментном шейдере мы теперь можем использовать eyeVector и worldNormal в качестве первых двух параметров во встроенной в glsl функции refract. Третий параметр — это соотношение показателей преломления, то есть индекс преломления (IOR) нашей плотной среды — стекла. В нашем случае он будет 1.0/1.5, но вы можете изменить это значение для достижения желаемого результата. Например, индекс преломления воды — 1.33, а алмаза — 2.42.


uniform sampler2D envMap;
uniform vec2 resolution;

varying vec3 worldNormal;
varying vec3 viewDirection;

void main() {
    // get screen coordinates
    vec2 uv = gl_FragCoord.xy / resolution;

    vec3 normal = worldNormal;
    // calculate refraction and add to the screen coordinates
    vec3 refracted = refract(eyeVector, normal, 1.0/ior);
    uv += refracted.xy;

    // sample the background texture
    vec4 tex = texture2D(envMap, uv);

    vec4 output = tex;
    gl_FragColor = vec4(output.rgb, 1.0);
}

https://codesandbox.io/embed/multi-side-refraction-step-13-pzxf9?fontsize=14&hidenavigation=1&theme=dark


Отлично! Мы успешно написали шейдер. Но алмаз едва виден… Частично потому, что мы обработали только одно свойство стекла. Не весь свет пройдет через него и будет преломлен, по сути, часть будет отражена. Давайте посмотрим, как этого достичь!


Шаг 2: Отражения и уравнение Френеля


Ради упрощения, в этом уроке мы не будем вычислять преломления по-настоящему, а просто используем для преломленного света белый цвет. Идем дальше: как узнать, когда отражать, а когда — преломлять? В теории, это зависит от индекса преломления материала: когда угол между падающим вектором и нормалью поверхности больше порогового значения, свет будет отражен.



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


float Fresnel(vec3 eyeVector, vec3 worldNormal) {
    return pow( 1.0 + dot( eyeVector, worldNormal), 3.0 );
}

Мы можем просто смешать цвет текстуры преломленного луча с отраженным белым цветом с помощью пропорции, которую только что вычислили.


uniform sampler2D envMap;
uniform vec2 resolution;

varying vec3 worldNormal;
varying vec3 viewDirection;

float Fresnel(vec3 eyeVector, vec3 worldNormal) {
    return pow( 1.0 + dot( eyeVector, worldNormal), 3.0 );
}

void main() {
    // get screen coordinates
    vec2 uv = gl_FragCoord.xy / resolution;

    vec3 normal = worldNormal;
    // calculate refraction and add to the screen coordinates
    vec3 refracted = refract(eyeVector, normal, 1.0/ior);
    uv += refracted.xy;

    // sample the background texture
    vec4 tex = texture2D(envMap, uv);

    vec4 output = tex;

    // calculate the Fresnel ratio
    float f = Fresnel(eyeVector, normal);

    // mix the refraction color and reflection color
    output.rgb = mix(output.rgb, vec3(1.0), f);

    gl_FragColor = vec4(output.rgb, 1.0);
}

https://codesandbox.io/embed/multi-side-refraction-step-23-3vdty?fontsize=14&hidenavigation=1&theme=dark


Выглядит уже намного лучше, но еще чего-то не хватает… Точно, мы не видим обратной стороны прозрачного объекта. Давайте это исправим!


Шаг 3: Многостороннее преломление


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



Создадим новый ShaderMaterial как на первом шаге, но теперь будем визуализировать карту нормалей в gl_FragColor.


varying vec3 worldNormal;

void main() {
    gl_FragColor = vec4(worldNormal, 1.0);
}

Дальше обновим цикл визуализации и добавим обработку задних граней.


this.backfaceFbo = new THREE.WebGLRenderTarget(width, height);

...

render() {
    requestAnimationFrame( this.render );

    this.renderer.clear();

    // render background to fbo
    this.renderer.setRenderTarget(this.envFbo);
    this.renderer.render( this.scene, this.orthoCamera );

    // render diamond back faces to fbo
    this.mesh.material = this.backfaceMaterial;
    this.renderer.setRenderTarget(this.backfaceFbo);
    this.renderer.clearDepth();
    this.renderer.render( this.scene, this.camera );

    // render background to screen
    this.renderer.setRenderTarget(null);
    this.renderer.render( this.scene, this.orthoCamera );
    this.renderer.clearDepth();

    // render diamond with refraction material to screen
    this.mesh.material = this.refractionMaterial;
    this.renderer.render( this.scene, this.camera );
};

Теперь используем текстуру с нормалями в материале.


vec3 backfaceNormal = texture2D(backfaceMap, uv).rgb;

И наконец, совместим нормали передних и задних граней.


float a = 0.33;
vec3 normal = worldNormal * (1.0 - a) - backfaceNormal * a;

В этом уравнении а — это просто скалярная величина, которая показывает, сколько использовать нормалей задних граней.


https://codesandbox.io/embed/multi-side-refraction-step-33-ljnqj?fontsize=14&hidenavigation=1&theme=dark


Получилось! Видны все стороны алмаза только лишь с помощью отражений и преломлений, которые мы добавили в материал.


Ограничения


Как я уже объяснил, получить правильные с физической точки зрения прозрачные материалы в реальном времени с помощью этого метода не очень-то возможно. Еще одна проблема состоит в том, чтобы визуализировать несколько стеклянных объектов, стоящих друг перед другом. Так как мы визуализируем фон только один раз, увидеть его через ряд объектов не получится. И наконец, преломления в области видимости, которые я здесь продемонстрировал, не будут работать нормально у границ экрана, потому что лучи могут преломляться со значениями, выходящими за границы плоскости, и мы не сможем отловить эти случаи во время визуализации сцены в текстуру.


Конечно, есть способы обойти эти ограничения, но не все они будут отличными решениями для WebGL.


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

Комментарии (0)