Сообщений 0    Оценка 0        Оценить  
Система Orphus

Прослеживание вокселей при рейкастинге для прямого объемного рендеринга

Автор: Арсланов Дмитрий Мерзагитович
Перевод: Арсланов Дмитрий Мерзагитович
Источник: RSDN Magazine
Материал предоставил: Арсланов Дмитрий Мерзагитович
Опубликовано: 23.07.2012
Исправлено: 10.12.2016
Версия текста: 1.1
Введение
Загрузка встроенного эффекта
Управление видом
Отбор значений вокселей
Прослеживание вокселей
Воксельные материалы
Затенение
Альфа-смешивание
Буферы
Градиент скалярного поля
Рендеринг
Результаты работы программы
Заключение
Список литературы

Введение

Объемные изображения воксельной графики представляют собой однозначное сопоставление действительного числа каждой точке пространства из конечного множества узлов прямоугольной регулярной трехмерной решетки. Такое сопоставление называется трехмерным дискретным скалярным полем (см. рис. 1). Сопоставляемое число в основном является плотностью или интенсивностью. Также могут рассматриваться индексы материалов. Отдельная точка и сопоставленное ей число называется вокселом и является элементом объемного изображения [8].


Рисунок 1. Объемное изображение в виде трехмерного дискретного скалярного поля.

Полигональная графика является самым распространенным видом современной трехмерной компьютерной графики [4]. Но по сравнению с ней такое представление не только внешнего, но и внутреннего строения тел является простым и удобным для сложной интерактивной компьютерной обработки данных объемных моделей. Примерами такой обработки являются объединение, пересечение и разность тел, подсчет объемов тел сложного строения, визуализация внутреннего строения тел, цифровая лепка и многое другое. В качестве примера в [1] рассматривается построение объемного изображения тела сложного строения с использованием графического процессора.

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

Центральной задачей обработки воксельной графики является объемный рендеринг. Это процесс получения растровых видов объемных изображений. Можно выделить два способа рендеринга воксельной графики. Первый состоит в получении растровых изображений сглаженных затененных цветных поверхностей равного значения скалярного поля. Вид при этом получают из точки, удаленной от модели настолько, чтобы можно было рассмотреть всю модель целиком или ее отдельные макрочасти. Примером может служить визуализация дымового эффекта, рендеринг научной объемной модели или медицинского скана компьютерной томографии. Так, один из примеров изображен на рисунке 2.


Рисунок 2. Пример сглаженного прямого объемного рендеринга.

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


Рисунок 3. Пример ступенчатого прямого объемного рендеринга.

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

Основными методами объемного рендеринга являются объемный рейкастинг или метод бросания луча в объеме (volume ray casting) [6], метод текстурирования сечений объема (texture-based volume rendering) [10], а также метод марширующих кубов (marching cubes) [3]. Первые два метода представляют собой прямой объемный рендеринг (direct volume rendering). Для формирования результирующего растра методы прямого рендеринга используют непосредственно данные объемного изображения, сопоставляя их с определенными цветами при помощи передаточной функции. В отличие от них, метод марширующих кубов строит для рендеринга объемного изображения его эквивалентную полигональную модель.

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

Обработка графики является достаточно ресурсоемкой задачей. Поэтому для обеспечения приемлемого уровня быстродействия эффективно использовать аппаратное ускорение. По этой причине реализация будет представлять собой программу для графического процессора, которая называется эффектом [11]. Для работы с графическим процессором будут использованы HLSL и DirectX [7]. Для демонстрации работы этой программы будет создано небольшое приложение на C# [5]. Для работы с DirectX на .Net будет применяться SharpDX [13].

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

Загрузка встроенного эффекта

Пусть текстовый файл VolumeRendering.fx содержит HLSL-код программы, разрабатываемой для графического процессора. Для его компиляции в байт-код графического процессора удобно воспользоваться следующим *.cmd-файлом.

"C:\Program Files (x86)\Microsoft DirectX SDK (June 2010)\Utilities\bin\x86\fxc.exe" /T fx_5_0 /Fo VolumeRendering.fxo /Fe VolumeRendering.txt VolumeRendering.fx
type VolumeRendering.txt
pause

На компьютере при этом должен быть установлен DirectX SDK. Также следует обратить внимание на кодировку файла. В файл VolumeRendering.txt записываются ошибки и предупреждения при компиляции. Откомпилированный байт-код эффекта сохраняется в файл VolumeRendering.fxo [7]. Этот файл включается в состав C#-проекта приложения в качестве встроенного ресурса исполняемой сборки (Build Action: Embedded Resource). Ниже приведено начало кода демонстрационного приложения, которое содержит инициализацию графической системы и загрузку встроенного в исполняемую сборку байт-кода эффекта для работы с ним в прикладном приложении. Управление высвобождением ресурсов приложения будем вести по стандартной схеме работы с интерфейсом IDisposable объектов .Net [5].

      using System;
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using SharpDX;
using SharpDX.D3DCompiler;
using SharpDX.Direct3D;
using SharpDX.Direct3D11;
using SharpDX.DXGI;
using SharpDX.Windows;
using Buffer = SharpDX.Direct3D11.Buffer;
using Device = SharpDX.Direct3D11.Device;
using MapFlags = SharpDX.Direct3D11.MapFlags;

namespace VolumeRendering
{
    class App : IDisposable
    {
        staticvoid Main()
        {
            App app = new App();
            app.Run();
            app.Dispose();
        }

        private Factory factory;
        private Adapter adapter;
        private Device device;

        internal App()
        {
            factory = new Factory();
            adapter = factory.GetAdapter(0);
            device = new Device(adapter,
                                DeviceCreationFlags.Debug,
                                FeatureLevel.Level_11_0);
            LoadEmbeddedEffect();
            CreateRenderForm();
            LoadVolume();
        }

        private Effect effect;
        privatestring technique;

        privatevoid LoadEmbeddedEffect()
        {
            ShaderBytecode effectBytecode = ShaderBytecode.Load(
                Assembly.GetExecutingAssembly().
                    GetManifestResourceStream(
                        "VolumeRendering.VolumeRendering.fxo"));
            effect = new Effect(device, effectBytecode);
            effectBytecode.Dispose();
            technique = "PreSmoothedShading";
        }

Загруженный эффект реализует объемный рейкастинг, удовлетворяющий поставленным требованиям. Его можно рассмотреть как процесс получения растрового вида объемного изображения при помощи виртуальной камеры, изображенный на рисунке 4 [2, 8, 11].


Рисунок 4. Общая схема объемного рейкастинга.

Управление видом

Пусть решетка объемного изображения, представленная на рисунке 1, сосредоточена внутри единичного куба. Введем базис [11] трехмерного евклидового пространства, который можно записать в виде кортежа правой тройки взаимно перпендикулярных векторов единичной длины, называемых ортами:

E D = (eD(i)).

Здесь и везде далее

i = 1, 2, 3.

Любую точку x внутри куба можно представить в виде линейной комбинации данных векторов, т.е. суммы базисных векторов с числовыми коэффициентами, которые при соответствующем базисном векторе eD(i) обозначим как xD(i). Эти коэффициенты представляют собой координаты точки x в пространстве ED. Обозначим вектор координат как

x D = (xD(i)).

Тогда разложение запишется в виде

x D = ∑xD(i)uD(i),

где индекс суммы пробегает значения i = 1, 2, 3.

Общее начало базисных векторов помещается в одну из вершин куба так, что координаты любой точки x внутри куба в пространстве ED удовлетворяют условию:

0 ≤ xD(i)≤ 1.

Теперь введем правый ортонормированный базис

E W = (eW(i)),

представляющий глобальное пространство. Вектор координат точки x внутри куба в пространстве EW обозначим как

x W = (xW(i)).

Как видно из рисунка 4, вектора базиса ED имеют некоторое произвольное положение, ориентацию и размеры относительно базиса EW, которое задается мировой матрицей W. Она равна произведению матриц M(m) преобразований масштабирования, переноса и вращения относительно векторов базиса EW векторов базиса ED:

W = ∏M(m),

где индекс произведения пробегает значения m = 1, 2, …, NM. Здесь NM – это целое количество матриц преобразований. При этом порядок умножения матриц задает порядок преобразований. Аналогичное положение, ориентацию и пропорции примет и соответствующее объемное изображение.

Для проведения матричных преобразований точки должны быть представлены в гомогенных или однородных координатах. Гомогенными координатами точки x с координатами x(i) в некотором базисе называются координаты векторов x’:

x’ = (x’(j)),

здесь и везде далее j = 1, 2, 3, 4, связанных с рассматриваемыми координатами точки x соотношениями:

x’(i) = x(i)x’(4),

x(i) = x’(i) / x’(4).

Гомогенные координаты произвольной точки получают, принимая

x’(4) = 1.

Гомогенные координаты являются четырехмерным представлением трехмерного пространства и позволяют применить к векторам трехмерного пространства полный спектр преобразований, включая, в частности, перенос и проецирование [11].

Таким образом, вектор координат точки в глобальном пространстве EW найдется по ее вектору координат в пространстве ED куба как

x W’ = WxD’.

Также по координатам точки в глобальном пространстве EW можно узнать ее положение в пространстве ED куба при помощи обратной мировой матрицы:

x D’ = W-1xW’.

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

S V = (SV(k)),

а двумерный вектор индексов светочувствительного элемента в матрице экрана виртуальной камеры как

U = (U(k)),

где

U(k) = 1, 2, …, SV(k).

Здесь и везде далее полагаем k = 1, 2.

Пусть положение экрана виртуальной камеры в глобальном пространстве EW задается базисом

E V = (eV(i)),

с началом в фокусе f виртуальной камеры, где eV(1) – вектор, задающий горизонтальное направление экрана камеры, eV(2) – вектор, задающий вертикальное направление экрана камеры, eV(3) – вектор, перпендикулярный экрану, определяющий направление камеры. Данный базис представляет собой UVN модель камеры в трехмерной компьютерной графике. Для получения координат точки в базисе EV по координатам данной точки в базисе EW используется видовая матрица V:

x V’ = VxW’.

При получении проекции удобно рассмотреть обратный ход световых лучей, падающих на экран камеры, которые назовем видовыми лучами. Через каждый светочувствительный элемент матрицы виртуальной камеры проходит один видовой луч. Существует два основных типа проекции, которые получают при помощи виртуальной камеры в трехмерной компьютерной графике: ортографическая и перспективная [2]. В ортографической проекции видовой луч перпендикулярен плоскости экрана. В перспективной проекции видовые лучи собираются в фокусе f виртуальной камеры. В данной работе будем рассматривать перспективную проекцию.

Проекция световых лучей вычисляется в пределах усеченной четырехугольной пирамиды видимости. Вершина пирамиды находится в фокусе f камеры. Базисный вектор eV(3) перпендикулярен основаниям пирамиды. Середина верхнего основания в базисе EV находится как

p N = zNeV(3),

а нижнего –

p F = zFeV(3),

где zN и zF – расстояние до ближней и до дальней плоскости отсечения соответственно.

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

A = SV(1) / SV(2),

а также углу α обзора камеры в вертикальной плоскости экрана.

Введем правый ортонормированный базис проективного пространства экрана

E P = (eP(i)),

с началом в точке pN, такой, что координаты любой точки x внутри пирамиды видимости в данном базисе будут удовлетворять условиям:

-1 ≤ xP(k)≤ 1,

0 ≤ xP(3)≤ 1.

Для вычисления координат точки x в базисе EP по координатам точки x в базисе EV используется матрица проекции P:

x P’ = PxV’.

Для определения вектора U индексов светочувствительного элемента в матрице экрана виртуальной камеры, на который спроецирована точка x, воспользуемся соотношениями:

U(k) = [SV(k)(xP(k) + 1) / 2] + 1,

где [.] – оператор округления до максимального целого, меньшего или равного представленному значению.

Таким образом, мы определили три основных базиса EW, EV и EP, матрицы W, V и P переходов между которыми определяют получаемый виртуальной камерой растровый вид объемного изображения в ходе прямого объемного рендеринга объемным рейкастингом. С видом соответствующих матриц можно познакомиться в [1]. Для получения глобальной матрицы перехода от объемного изображения к его проекции на растровый видовой экран соответствующие матрицы перемножаются в порядке переходов между базисами:

x P’ = WVPxD’.

В ходе работы программы получаемый вид должен постоянно изменяться пользователем для того, чтобы лучше рассмотреть различные детали объемного изображения. Для этого необходимо обновлять все соответствующие матрицы. Поскольку фокус f камеры изначально задается в глобальном пространстве EW, а для выполнения объемного рейкастинга требуются его координаты в пространстве объемного изображения ED, при каждом обновлении требуется осуществлять переход между этими двумя базисами при помощи обратной мировой матрицы W-1. В листинге ниже приведен соответствующий код инициализации и обновления мировой и глобальной матриц.

      private Matrix boundsTrans;
        privatefloat scale;
        private Matrix scaling;
        private Matrix transform;
        private Matrix rotation;
        private Matrix translation;
        private Matrix worldViewProj;
        private Vector3 eye;
        private Point mouseOrigin;

        privatevoid InitTransforms()
        {
            float max = (float)Math.Max(volume.Description.Width,
                Math.Max(volume.Description.Height,
                         volume.Description.ArraySize));
            boundsTrans = Matrix.Translation(-0.5f, -0.5f, -0.5f) *
                Matrix.Scaling(volume.Description.Width / max,
                    volume.Description.Height / max,
                    volume.Description.ArraySize / max);
            scale = 1.0f;
            scaling = Matrix.Scaling(scale);
            transform = Matrix.Identity;
            rotation = Matrix.Identity;
            translation = Matrix.Identity;
            Transform();
        }

        privatevoid Transform()
        {
            Matrix world = boundsTrans *
                           scaling *
                           transform *
                           rotation *
                           translation;
            Matrix invWorld = Matrix.Invert(world);
            Vector4 eyeH = Vector3.Transform(Vector3.UnitZ, invWorld);
            eye = new Vector3(eyeH.X / eyeH.W,
                              eyeH.Y / eyeH.W,
                              eyeH.Z / eyeH.W);
            worldViewProj = world * view * projection;
        }

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

      private
      void RenderFormMouseDown(object sender, MouseEventArgs e)
        {
            mouseOrigin = e.Location;
        }

        privatevoid RenderFormMouseUp(object sender, MouseEventArgs e)
        {
            transform *= rotation * translation;
            rotation = Matrix.Identity;
            translation = Matrix.Identity;
        }

        privatevoid RenderFormMouseMove(object sender, MouseEventArgs e)
        {
            Point mousePosition = e.Location;
            if (e.Button == MouseButtons.Left)
            {
                rotation = Matrix.RotationX(
                    (float)(mousePosition.Y - mouseOrigin.Y) /
                    (float)renderForm.ClientSize.Height *
                    (float)Math.PI / 2.0f) *
                    Matrix.RotationY(
                        (float)(mousePosition.X - mouseOrigin.X) /
                        (float)renderForm.ClientSize.Width *
                        (float)Math.PI / 2.0f);
                Transform();
            }
            elseif (e.Button == MouseButtons.Middle)
            {
                translation = Matrix.Translation(
                    (float)(mousePosition.X - mouseOrigin.X) /
                    (float)renderForm.ClientSize.Width,
                    (float)(mouseOrigin.Y - mousePosition.Y) /
                    (float)renderForm.ClientSize.Height, 0.0f);
                Transform();
            }
        }

        privatevoid RenderFormMouseWheel(object sender, MouseEventArgs e)
        {
            scale += Math.Sign(e.Delta) * 0.1f;
            if (scale > 5.0f) scale = 5.0f;
            if (scale < 0.1f) scale = 0.1f;
            scaling = Matrix.Scaling(scale);
            Transform();
        }

Отбор значений вокселей

Процесс объемного рейкастинга, изображенный на рисунке 4, заключается в поочередном вычислении координат точек s отбора вокселей вдоль видовых лучей в пределах усеченной пирамиды видимости в базисе ED. Существует два способа отбора значений вокселей в данных точках s: блочный и интерполированный. Для описания этих способов рассмотрим рисунок 1. Обозначим целое количество узлов объемной решетки вдоль каждой из сторон куба

S D = (SD(i)).

Данный вектор задает разрешающую способность объемного изображения. Чем больше разрешающая способность, тем более детальным может быть изображение. Типовыми значениями для количества узлов решетки вдоль одной из сторон куба являются, например, 32, 64, 128, 256, 512, 1024 и т.п.

Обозначим вектор координат узлов объемной решетки в базисе ED как

b D = (bD(i)).

Координаты узлов объемной решетки удовлетворяют условиям

b D(i) = (BD(i) - 1)SB(i),

где номера узлов объемной решетки, отсчитываемые от точки начала базиса вдоль каждого из ортов

B D(i) = 1, 2, …, SD(i)

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

B D = (BD(i)).

Вектор размеров ячейки объемной решетки вдоль каждого из ортов обозначим как

S B = (SB(i)).

Его элементы найдутся как

S B(i) = 1 / SD(i).

Пусть скалярное поле представляется в виде кортежа

d = (d(bD)).

Значения скалярного поля удовлетворяют условиям

0 ≤ d(bD) ≤ 1

и

d(bD) = (D(BD) - 1)d0,

где целочисленное значение скалярного поля

D(BD) = 1, 2, …, ND,

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

N D = 2B,

где B – разрядность значений скалярного поля.

Дискрета значений скалярного поля найдется как

d 0 = 1 / ND.

Таким образом, скалярное поле объемного изображения может быть представлено в виде трехмерной дискретной целочисленной функции или трехмерной матрицы

D = (D(BD)).

Элементы матрицы находятся как

D(BD) = [d(bD) / d0] + 1,

где действительные координаты точки bD вычисляются по целочисленным значениям индекса BD.

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

Элементы D трехмерной матрицы D хранятся в трехмерной текстуре или массиве двумерных текстур. В данной работе B = 8, что соответствует ND = 256 допустимым значениям скалярного поля. В этом случае для хранения элемента матрицы используется 1 байт. Однако могут применяться и другие типы данных.

Рассмотрим рисунок 5, на котором изображен параллелепипед с размерами SB = (SB(i)) вдоль соответствующих ортов eD. Вершина параллелепипеда с минимальными координатами помещается в точку b000, в которой определено значение D000 скалярного поля.


Рисунок 5. Трилинейная интерполяция значений трехмерного дискретного скалярного поля.

При рендеринге вокселей в виде блоков крупным планом значения D скалярного поля во всех точках xD внутри этого параллелепипеда принимаются равными значению D000 скалярного поля в его вершине b000 с минимальными координатами. Этот параллелепипед и представляет собой воксель.

Для устранения эффектов ступенчатости при сглаженном рендеринге воксельных поверхностей значение D скалярного поля в точке xD внутри рассматриваемого параллелепипеда трилинейно интерполируется между значениями D000D111 скалярного поля в его вершинах b000b111. Для этого определяются номера BD(i) узла объемной решетки в вершине b000 с минимальными координатами вдоль соответствующих ортов базиса ED:

B D(i) = [xD(i) / SB(i)] + 1.

Далее находятся смещения точки xD относительно узла b000 вдоль соответствующих ортов eD:

xD(i) = xD(i) - (BD(i) - 1)SB(i).

При этом смещения удовлетворяют условиям

0 ≤ ∆xD(i) ≤ 1.

Затем для всех пар индексов

l = 0, 1 и m = 0, 1

находятся координаты узлов

b lm 0 = ((BD(1) + l - 1)SB(1), (BD(2) + m - 1)SB(2), (BD(3) - 1)SB(3)),

b lm 1 = ((BD(1) + l - 1)SB(1), (BD(2) + m - 1)SB(2), BD(3)SB(3)),

и значения скалярного поля между узлами линейно интерполируются в пространстве

D lm = D(blm0)(1 - ∆xD(3)) + D(blm1)∆xD(3),

D m = Dm0(1 - ∆xD(2)) + Dm1xD(2).

Таким образом, искомое значение D скалярного поля в промежуточной точке xD между узлами b000b111 найдется как

D = D0(1 - ∆xD(1)) + D1xD(1).

И хотя при блочном рендеринге вокселей интерполяция для значений скалярного поля не используется, она может успешно применяться для других данных, например, градиента, цвета и т.п., по аналогии. Использование графическим процессором интерполяции для определенных значений настраивается в HLSL при определении способа чтения из соответствующей текстуры. Для этого используется поле Filter структуры SamplerState. Например, значение MIN_MAG_MIP_LINEAR задает линейную интерполяцию при чтении в промежуточных точках, а MIN_MAG_MIP_POINT – чтение в промежуточной точке по ее целым координатам. Аналогичный результат дает оператор []. При этом также в полях Address указываются значения, считываемые за пределами текстуры. Так Clamp – это чтение значений с границы, а Border – значений, указанных в поле BorderColor, как правило, это нулевой вектор. В качестве примера ниже приведен листинг с настройкой способа чтения из текстуры с линейной интерполяцией и считыванием нулевых значений за пределами текстуры.

      SamplerState LinearBorderSampling
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = Border;
    AddressV = Border;
    AddressW = Border;
    BorderColor = float4(0, 0, 0, 0);
};

Прослеживание вокселей

Как правило, шаг отбора вокселей при рейкастинге выбирается согласно теореме Котельникова [8] по разрешающей способности SD объемного изображения и остается неизменным. Однако при рендеринге вокселей крупным планом это приводит к тому, что лучи, изображенные на рисунке 6, идущие через края вокселей, пропускают их из-за неподходящего шага (участки пропуска изображены пунктиром, расстояние между точками равно шагу отбора вокселей).


Рисунок 6. Пропуск вокселей вдоль луча из-за неподходящего шага.

Как видно из рисунка 7, в результате вблизи края вокселей выглядят рваными.


Рисунок 7. Визуальные артефакты при рендеринге вокселей крупным планом объемным рейкастингом с постоянным шагом.

Для корректного рендеринга вокселей крупным планом при помощи объемного рейкастинга вдоль каждого луча необходимо отбирать каждый воксель, через который проходит данный луч, в порядке следования. Этот процесс называется прослеживанием вокселей (voxel traversal). По рисунку 8 рассмотрим механизм прослеживания вокселей на примере прохождения видового луча через решетку скалярного поля объемного изображения в базисе ED.


Рисунок 8. Прослеживание вокселей при бросании луча.

Пусть рассматриваемый луч пересекает единичный куб объемного изображения в точках h и H, где h – первая, а H – вторая точка пересечения по ходу луча. Если камера расположена внутри единичного куба, то первой точки пересечения не будет, и в этом случае будем принимать

h = f.

Если луч ориентирован так, что нет и второй точки пересечения, то луч не проходит через скалярное поле, и рассмотрение в этом случае не ведется.

Координаты точек пересечения h и H могут быть получены с использованием графического процессора в пиксельном шейдере путем интерполяции в точках экрана, покрытых проекциями растеризуемых треугольников граней единичного куба, между значениями координат вершин единичного куба, возвращаемыми вершинным шейдером. Это достигается благодаря использованию соответствующих семантик, например, TEXCOORD0, NORMAL0.

В листинге ниже приведен код создания триангуляционной модели единичного куба объемного изображения для правой системы координат.

      private InputLayout inputLayout;
        private Buffer vertexBuffer;
        private Buffer indexBuffer;

        privatevoid CreateCube()
        {
            inputLayout = new InputLayout(device,
                effect.GetGroupByName("VolumeRendering").
                    GetTechniqueByName("PreSmoothedShading").
                    GetPassByName("Render").Description.Signature,
                new InputElement[]
                {
                    new InputElement("POSITION", 0,
                        Format.R32G32B32_Float, 0, 0)
                });
            vertexBuffer = Buffer.Create<Vector3>(device,
                BindFlags.VertexBuffer,
                new Vector3[]
                {
                    new Vector3(0.0f, 0.0f, 0.0f),
                    new Vector3(0.0f, 0.0f, 1.0f),
                    new Vector3(0.0f, 1.0f, 0.0f),
                    new Vector3(0.0f, 1.0f, 1.0f),
                    new Vector3(1.0f, 0.0f, 0.0f),
                    new Vector3(1.0f, 0.0f, 1.0f),
                    new Vector3(1.0f, 1.0f, 0.0f),
                    new Vector3(1.0f, 1.0f, 1.0f)
                });
            indexBuffer = Buffer.Create<int>(device,
                BindFlags.IndexBuffer,
                newint[]
                {
                    0, 1, 2,
                    1, 3, 2,
                    0, 2, 4,
                    2, 6, 4,
                    4, 6, 5,
                    5, 6, 7,
                    1, 5, 3,
                    3, 5, 7,
                    2, 3, 6,
                    3, 7, 6,
                    0, 4, 1,
                    1, 4, 5
                });
        }

В следующем листинге приведен HLSL-код вершинного и пиксельного шейдеров, вычисляющих точки пересечения лучей с гранями куба.

      float4x4 WorldViewProj;

struct Vertex
{
    float4 Projection : SV_POSITION;
    float4 RayHitScreen : TEXCOORD0;
    float3 RayHitBox : NORMAL0;
};

Vertex VS(float3 input : POSITION0)
{
    Vertex output;
    output.Projection = mul(float4(input, 1.0f), WorldViewProj);
    output.Projection.z *= output.Projection.w;
    output.RayHitScreen = output.Projection;
    output.RayHitBox = input;
    return output;
}

float4 RayHitBoxPS(Vertex input) : SV_TARGET0
{
    returnfloat4(input.RayHitBox, 1.0f);
}

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

      RasterizerState FrontCulling
{
    FillMode = SOLID;
    CullMode = FRONT;
    FrontCounterClockwise = TRUE;
    DepthBias = 0.0f;
    DepthBiasClamp = 0.0f;
    SlopeScaledDepthBias = 0.0f;
    DepthClipEnable = TRUE;
    ScissorEnable = FALSE;
    MultisampleEnable = FALSE;
    AntialiasedLineEnable = FALSE;
};

Проследим все воксели между точками h и H в порядке прохождения через них рассматриваемого луча. На рисунке 8 это (0, 4), (1, 4), (2, 4), (3, 4) (4, 4), (4, 5), (5, 5) и (6, 5). Для этого будем искать следующую ближайшую точку s пересечения луча с гранями параллелепипедов вокселей по ходу движения вдоль луча. Пусть плоскость грани проходит через узел b и имеет единичную нормаль n, которая имеет противоположное eV(3) направление. Плоскость задается уравнением

(s - bn = 0.

Здесь символ ∙ обозначает скалярное произведение векторов. Точка пересечения s лежит на луче, задаваемом параметрическим уравнением

s = h + r(H - h).

Подставляя уравнение луча в уравнение плоскости, найдем смещение r вдоль луча от начала луча h до точки пересечения H:

(h + r(H - h) - bn = 0,

(h - bn + r(H - hn = 0,

r(H - hn = (b - hn,

r = (b - hn / ((H - hn).

Подставив найденное смещение r в уравнение луча, получим искомую точку пересечения s. Следует обратить внимание, что луч может быть параллелен плоскости, т.е. не пересекать плоскость. При этом

(H - hn = 0.

Луч может также полностью лежать в плоскости, тогда дополнительно и

(b - hn = 0.

В обоих случаях будем полагать, что точки пересечения s нет: она или бесконечно удалена в первом случае, или их бесконечное множество – во втором. Кроме того, луч должен пересекать плоскости строго внутри единичного куба объемного изображения, таким образом, найденное смещение также должно удовлетворять условию

0 ≤ r ≤ 1.

Целочисленные координаты отбираемого пересечением вокселя найдутся как

B D(i) = [s(i) / SB(i)] + 1,

где s(i) – координаты точки пересечения в базисе ED.

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

Воксельные материалы

В найденных точках s пересечения луча и грани вокселя происходит определение цвета cT, соответствующего отобранному вокселю, и затенение. Все цвета в работе кодируются в формате RGBA. Цвет cT вокселя зависит от цвета применяемого освещения и векторов коэффициентов общего освещения и собственного излучения KA, рассеивания KD и зеркального отражения KS вокселя, а также размера зеркального блика S [2, 8, 11]. В совокупности эти данные образуют воксельный материал. Векторы материала имеют размерность 4x1 и содержат коэффициенты отражаемого поверхностью вокселя цвета падающего излучения для соответствующего цветового канала RGBA. Так если вектор коэффициентов нулевой, то поверхность поглощает все падающее излучение и, соответственно, имеет черный цвет. Если же вектор коэффициентов единичный, то поверхность, напротив, отражает все падающее излучение и при освещении светом с полным видимым спектром цветов выглядит белой. Однако при облучении только красным цветом поверхность будет выглядеть красной. Причем для каждой точки поверхности граней вокселей эти векторы могут иметь собственное значение. По аналогии с рисунком 1 это можно рассмотреть как наложение на поверхность грани вокселя текстуры, которая представляет собой плоское векторное поле, являющееся однозначным сопоставлением узлам двумерной прямоугольной регулярной решетки указанных векторов коэффициентов.

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

E T = (eT(k)).

Координаты любой точки внутри квадрата в данном базисе ET

y T = (yT(k))

удовлетворяют условиям

0 ≤ yT(k)≤ 1.

Обозначим количество узлов решетки вдоль соответствующих ортов

S T = (ST(k)),

тогда координаты узлов решетки

t T = (tT(k))

удовлетворяют условиям

t T(k) = (TT(k) - 1)ST(k),

где элементы двумерного вектора индексов узла в решетке

T T = (TT(k))

принимают значения

T T(k) = 1, 2, …, ST(k).

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

K = K(d, tT).

Вектор коэффициентов KA общего освещения в данной точке s грани вокселя определяет отраженный от поверхности цвет или собственный излучаемый поверхностью цвет, когда на нее не падают лучи света. В этом случае световые лучи идут параллельно поверхности или направлены противоположно вектору нормали поверхности в данной точке s, которую обозначим как n. Вектор коэффициентов KD рассеивания определяет отраженный от поверхности рассеянный материалом вокселя цвет источника. Вектор коэффициентов зеркального отражения KS задает отражение лучей источника света от поверхности, причем степень S определяет действительный размер этого отражения или зеркального блика.

Сумма соответствующих элементов векторов коэффициентов материала не должна превышать 1:

K(j) ≤ 1,

где индекс суммы пробегает значения j = 1, 2, 3, 4.

На рисунках 9 – 12 для примера приведены текстуры мрамора, показывающие соотношения различных коэффициентов материалов.


Рисунок 9. Исходная текстура.


Рисунок 10. Текстура общего освещения, полученная из исходной текстуры путем снижения осветленности на 75%.


Рисунок 11. Рассеивающая текстура, полученная из исходной текстуры путем снижения осветленности на 25%.


Рисунок 12. Зеркальная текстура, полученная из исходной текстуры путем инвертирования цветов.

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

      private Texture2D volume;
        private ShaderResourceView volumeSRV;

        privatevoid LoadVolume()
        {
            CreateCube();
            volume = Texture2D.FromFile<Texture2D>(device, "Volume.dds",
                new ImageLoadInformation()
                {
                    BindFlags = BindFlags.ShaderResource,
                    CpuAccessFlags = CpuAccessFlags.None,
                    Depth = 0,
                    Filter = FilterFlags.Point,
                    FirstMipLevel = 0,
                    Format = Format.R8_UNorm,
                    Height = 1024,
                    MipFilter = FilterFlags.Point,
                    MipLevels = 1,
                    OptionFlags = ResourceOptionFlags.None,
                    PSrcInfo = IntPtr.Zero,
                    Usage = ResourceUsage.Default,
                    Width = 1024
                });
            volumeSRV = new ShaderResourceView(device, volume);
            Gradient();
            CreateMaterials();
            InitTransforms();
        }

В листинге ниже приведен код создания массива воксельных материалов, а также загрузки текстур общего освещения с диска. В работе применяется два типа текстур размером 128x128: текстуры с изображением и сплошным цветом, которые переключаются в ходе работы программы. Остальные текстуры загружаются аналогично.

      private ImageLoadInformation info;
        private Texture2DDescription desc;
        privatebool colored;
        private Texture2D ambient;
        private ShaderResourceView ambientSRV;
        private Texture2D diffuse;
        private ShaderResourceView diffuseSRV;
        private Texture2D specular;
        private ShaderResourceView specularSRV;
        private Texture2D ambientColor;
        private ShaderResourceView ambientColorSRV;
        private Texture2D diffuseColor;
        private ShaderResourceView diffuseColorSRV;
        private Texture2D specularColor;
        private ShaderResourceView specularColorSRV;
        private Texture1D specularPower;
        private ShaderResourceView specularPowerSRV;

        privatevoid CreateMaterials()
        {
            info = new ImageLoadInformation()
            {
                BindFlags = BindFlags.ShaderResource,
                CpuAccessFlags = CpuAccessFlags.None,
                Depth = 1,
                Filter = FilterFlags.Point,
                FirstMipLevel = 0,
                Format = Format.R8G8B8A8_UNorm,
                Height = 128,
                MipFilter = FilterFlags.Point,
                MipLevels = 1,
                OptionFlags = ResourceOptionFlags.None,
                PSrcInfo = IntPtr.Zero,
                Usage = ResourceUsage.Default,
                Width = 128
            };
            desc = new Texture2DDescription()
            {
                ArraySize = 256,
                BindFlags = BindFlags.ShaderResource,
                CpuAccessFlags = CpuAccessFlags.None,
                Format = Format.R8G8B8A8_UNorm,
                Height = 128,
                MipLevels = 1,
                OptionFlags = ResourceOptionFlags.None,
                SampleDescription = new SampleDescription(1, 0),
                Usage = ResourceUsage.Default,
                Width = 128
            };
            LoadAmbient();
            LoadDiffuse();
            LoadSpecular();
            CreateSpecularPower();
            background = Colors.DeepSkyBlue;
        }

        privatevoid LoadAmbient()
        {
            ambient = new Texture2D(device, desc);
            ambientSRV = new ShaderResourceView(device, ambient);
            Texture2D loadedTexture = Texture2D.FromFile<Texture2D>(
                device, "64a.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambient, 64, 0, 0, 0);
            loadedTexture.Dispose();
            loadedTexture = Texture2D.FromFile<Texture2D>(device,
                "128a.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambient, 128, 0, 0, 0);
            loadedTexture.Dispose();
            loadedTexture = Texture2D.FromFile<Texture2D>(device,
                "192a.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambient, 192, 0, 0, 0);
            loadedTexture.Dispose();
            ambientColor = new Texture2D(device, desc);
            ambientColorSRV = new ShaderResourceView(device,
                ambientColor);
            loadedTexture = Texture2D.FromFile<Texture2D>(device,
                "64ac.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambientColor, 64, 0, 0, 0);
            loadedTexture.Dispose();
            loadedTexture = Texture2D.FromFile<Texture2D>(device,
                "128ac.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambientColor, 128, 0, 0, 0);
            loadedTexture.Dispose();
            loadedTexture = Texture2D.FromFile<Texture2D>(device,
                "192ac.png", info);
            device.ImmediateContext.CopySubresourceRegion(loadedTexture,
                0, null, ambientColor, 192, 0, 0, 0);
            loadedTexture.Dispose();
        }

        privatevoid CreateSpecularPower()
        {
            specularPower = new Texture1D(device,
                new Texture1DDescription()
                {
                    ArraySize = 1,
                    BindFlags = BindFlags.ShaderResource,
                    CpuAccessFlags = CpuAccessFlags.Write,
                    Format = Format.R32_Float,
                    MipLevels = 1,
                    OptionFlags = ResourceOptionFlags.None,
                    Usage = ResourceUsage.Dynamic,
                    Width = 256
                });
            specularPowerSRV = new ShaderResourceView(device,
                specularPower);
            DataStream ds;
            DataBox db = device.ImmediateContext.MapSubresource(
                specularPower, 0, MapMode.WriteDiscard,
                MapFlags.None, out ds);
            for (int i = 0; i < 256; i++)
            {
                ds.Position = i * 4;
                ds.Write<float>(5000.0f);
            }
            device.ImmediateContext.UnmapSubresource(specularPower, 0);
            ds.Dispose();
        }

Затенение

Освещенность поверхности в точке s зависит от угла между нормалью n и направлением на источник света, а также его типа [2, 8, 11]. Она будет максимальной, когда нормаль n и направление света сонаправлены. Как правило, выделяют четыре типа источников света: общий, направленный, точечный и прожектор. В данной работе используется один направленный источник света, который имитирует естественное (дневное) или искусственное направленное освещение. Этого более чем достаточно для задач, решаемых создаваемой программой. Лучи света данного источника идут во всем пространстве параллельно друг другу в направлении единичного вектора L и имеют цвет cL.

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

если nL > 0, тогда

c T = (KA + (nL)KD + (R∙||s||)SKS)•cL,

иначе

c T = KAcL.


Рисунок 13. Векторная диаграмма отражательной модели Фонга.

Здесь символ • обозначает модуляцию или поэлементное умножение векторов.

R – это вектор направления отраженных от поверхности световых лучей, который находится как

R = 2(nL)nL.

Символ ||.|| обозначает оператор нормирования вектора, который на примере вектора нормали

n = (n(i))

определяется как

||n|| = n / |n|,

где символ |.| обозначает оператор вычисления длины вектора, который работает согласно формуле

|n| = √∑n(i)2,

где индекс суммы пробегает значения i = 1, 2, 3.

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

Альфа-смешивание

Результирующий цвет, приходящий в светочувствительный элемент U экрана камеры из всех точек s пересечения соответствующего луча с поверхностями материалов на его пути, найдется в результате обратного альфа-смешивания (front to back alpha blending) цветов cT, приходящих от отдельных точек пересечения s [2, 8, 11]. Это эквивалентно подкладыванию очередного исходного растра src из смешиваемой очереди под текущий результирующий растр dst, изображенному на рисунке 14.


Рисунок 14. Обратное альфа-смешивание воксельных материалов.

Альфа-канал при обратном смешивании найдется как

a R = adst + asrc(1 - adst),

если aR = 0, то cR = 0,

в противном случае

c R = cdstadst + csrcasrc(1 – adst) / a.

При наложении смешиваемых изображений в обратном порядке, сверху, выполняется прямое альфа-смешивание (back to front alpha blending). В этом случае растры src и dst и их индексы меняются местами.

В листинге ниже приведено описание режима альфа-смешивания для графического конвейера.

      BlendState AlphaBlending
{
    AlphaToCoverageEnable = TRUE;
    BlendEnable[0] = TRUE;
    BlendEnable[1] = TRUE;
    BlendEnable[2] = TRUE;
    BlendEnable[3] = TRUE;
    BlendEnable[4] = TRUE;
    BlendEnable[5] = TRUE;
    BlendEnable[6] = TRUE;
    BlendEnable[7] = TRUE;
    SrcBlend = SRC_ALPHA;
    DestBlend = INV_SRC_ALPHA;
    BlendOp = ADD;
    SrcBlendAlpha = ONE;
    DestBlendAlpha = ONE;
    BlendOpAlpha = ADD;
    RenderTargetWriteMask[0] = 0xf;
    RenderTargetWriteMask[1] = 0xf;
    RenderTargetWriteMask[2] = 0xf;
    RenderTargetWriteMask[3] = 0xf;
    RenderTargetWriteMask[4] = 0xf;
    RenderTargetWriteMask[5] = 0xf;
    RenderTargetWriteMask[6] = 0xf;
    RenderTargetWriteMask[7] = 0xf;
};

Буферы

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

При ходе луча из светочувствительного элемента в случае, если его альфа-канал при смешивании текстур на некотором шаге прослеживания вокселей становится непрозрачным (aR = 1), дальнейшее прослеживание вокселей можно прекратить. Расстояние sP(3) от экрана до соответствующей точки s пересечения с поверхностью вокселя для каждого луча сохраняется для проведения теста глубины. Это приводит к необходимости иметь буфер глубины тех же размеров, что и задний буфер. При рендеринге нескольких объемных моделей разных типов тест глубины обеспечит корректное перекрытие их проекций на видовой экран. Достигается это тем, что цвет, получаемый для отдельного пикселя вида при следующем рендеринге, не смешивается с хранящимся в нем значением от предыдущего рендеринга, если значение, хранящееся в буфере глубины, меньше нового полученного значения. В противном случае смешивание производится, и значение в буфере глубины обновляется [2, 11].

При работе с буфером глубины существует проблема линеаризации его значений. Для обеспечения линейного изменения значений буфера глубины при линейном изменении удаленности модели от видового экрана, в работе применяется способ, описанный в [1].

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

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

      private RenderForm renderForm;
        private Label statusBarLabel;
        private SwapChain swapChain;
        private Texture2D renderTarget;
        private RenderTargetView renderTargetView;
        private Texture2D rayHitBox;
        private RenderTargetView rayHitBoxRTV;
        private ShaderResourceView rayHitBoxSRV;
        private Texture2D depthStencil;
        private DepthStencilView depthStencilView;
        private Matrix projection;

        privatevoid CreateRenderForm()
        {
            renderForm = new RenderForm()
            {
                Text = "Арсланов Д. М. Объемный рендеринг.",
                MinimumSize = new Size(640, 480),
                ClientSize = new Size(800, 600),
                StartPosition = FormStartPosition.CenterScreen
            };
            statusBarLabel = new Label() { Dock = DockStyle.Bottom };
            renderForm.Controls.Add(statusBarLabel);
            swapChain = new SwapChain(factory, device,
                new SwapChainDescription()
                {
                    BufferCount = 1,
                    Flags = SwapChainFlags.None,
                    IsWindowed = true,
                    ModeDescription = new ModeDescription(
                        renderForm.ClientSize.Width,
                        renderForm.ClientSize.Height,
                        new Rational(60, 1),
                        Format.R8G8B8A8_UNorm),
                    OutputHandle = renderForm.Handle,
                    SampleDescription = new SampleDescription(1, 0),
                    SwapEffect = SwapEffect.Discard,
                    Usage = Usage.RenderTargetOutput
                });
            view = Matrix.LookAtRH(Vector3.UnitZ,
                                   Vector3.Zero,
                                   Vector3.UnitY);
            InitBuffers();
            renderForm.Resize += new EventHandler(RenderFormResize);
            renderForm.MouseDown += new MouseEventHandler(
                RenderFormMouseDown);
            renderForm.MouseUp += new MouseEventHandler(
                RenderFormMouseUp);
            renderForm.MouseMove += new MouseEventHandler(
                RenderFormMouseMove);
            renderForm.MouseWheel += new MouseEventHandler(
                RenderFormMouseWheel);
            renderForm.KeyPress += new KeyPressEventHandler(
                RenderFormKeyPress);
        }
        privatevoid InitBuffers()
        {
            renderTarget = Texture2D.FromSwapChain<Texture2D>(swapChain,
                                                              0);
            renderTargetView = new RenderTargetView(device, renderTarget);
            rayHitBox = new Texture2D(device, new Texture2DDescription()
            {
                ArraySize = 1,
                BindFlags = BindFlags.RenderTarget |
                            BindFlags.ShaderResource,
                CpuAccessFlags = CpuAccessFlags.None,
                Format = Format.R32G32B32A32_Float,
                Height = renderForm.ClientSize.Height,
                MipLevels = 1,
                OptionFlags = ResourceOptionFlags.None,
                SampleDescription = new SampleDescription(1, 0),
                Usage = ResourceUsage.Default,
                Width = renderForm.ClientSize.Width
            });
            rayHitBoxRTV = new RenderTargetView(device, rayHitBox);
            rayHitBoxSRV = new ShaderResourceView(device, rayHitBox);
            depthStencil = new Texture2D(device,
                new Texture2DDescription()
                {
                    ArraySize = 1,
                    BindFlags = BindFlags.DepthStencil,
                    CpuAccessFlags = CpuAccessFlags.None,
                    Format = Format.D32_Float,
                    Height = renderForm.ClientSize.Height,
                    MipLevels = 1,
                    OptionFlags = ResourceOptionFlags.None,
                    SampleDescription = new SampleDescription(1, 0),
                    Usage = ResourceUsage.Default,
                    Width = renderForm.ClientSize.Width
                });
            depthStencilView = new DepthStencilView(device, depthStencil);
            float zfar = 100.0f;
            projection = Matrix.PerspectiveFovRH(
                60.0f / 180.0f * (float)Math.PI,
                (float)renderForm.ClientSize.Width /
                    (float)renderForm.ClientSize.Height,
                0.1f,
                zfar);
            projection.M33 /= zfar;
            projection.M43 /= zfar;
            device.ImmediateContext.Rasterizer.SetViewports(
                new Viewport(0, 0, renderForm.ClientSize.Width,
                    renderForm.ClientSize.Height));
        }

        privatevoid RenderFormResize(object sender, EventArgs e)
        {
            ReleaseBuffers();
            swapChain.ResizeBuffers(1, renderForm.ClientSize.Width,
                renderForm.ClientSize.Height, Format.R8G8B8A8_UNorm, 0);
            InitBuffers();
        }

        privatevoid ReleaseBuffers()
        {
            rayHitBoxSRV.Dispose();
            rayHitBoxRTV.Dispose();
            rayHitBox.Dispose();
            depthStencilView.Dispose();
            depthStencil.Dispose();
            renderTargetView.Dispose();
            renderTarget.Dispose();
        }

Градиент скалярного поля

Рассмотрим способы вычисления нормали n к поверхности вокселя в точке s пересечения с ней видового луча камеры. Зная плоскость грани вокселя, с которой произошло пересечение: YOZ, XOZ или XOY, – можно определить нормаль n для ее плоского затенения, как показано в HLSL листинге ниже.

      float3 unit[3] =
{
    float3(1.0f, 0.0f, 0.0f),
    float3(0.0f, 1.0f, 0.0f),
    float3(0.0f, 0.0f, 1.0f)
};
float3 normal = -lookSign[plane] * unit[plane];

Здесь lookSign – знак соответствующей координаты направления видового луча ||s||, а plane – индекс плоскости и перпендикулярной ей оси. Для плоскости YOZ и оси OX – это 0, XOZ и OY – 1, а XOY и OZ – 2. Такой способ пригоден для плоского затенения при рендеринге вокселей с близкого расстояния. В прочих же случаях предпочтительно использовать градиент скалярного поля.

Градиент скалярного поля играет важную роль при рендеринге и моделировании объемных изображений. Это вектор частных производных скалярного поля по направлениям ортов базиса ED:

grad(D(BD)) = (∂D(BD)/∂BD(i)).

Следует отметить, что принятое обозначение производной по направлению является условным, она ищется в конечных разностях для целочисленной дискретной функции скалярного поля. Таким образом, ее можно записать как свертку дискретной функции D с ядром фильтра специального вида F(i) [12]. При этом для каждого направления i = 1, 2, 3 используется свой фильтр. Обозначим свертку символом * и будем искать ее в форме трехмерной матрицы

D*F(i) = (G(i, BD)).

Тогда производная по направлению в точке BD, в которой определено значение скалярного поля, равна элементу этой матрицы

D(BD)/∂BD(i) = G(i, BD).

Фильтр можно рассматривать как трехмерную матрицу нечетной размерности из действительных коэффициентов:

F(i) = (F(i, QF)),

где трехмерный индекс элемента этой матрицы

Q F = (QF(i)),

элементы которого принимают значения

Q F(i) = 1, 2, …, NF,

здесь NF – это нечетный размер матрицы фильтра.

Элементы матрицы свертки находятся как

G(i, BD) = ∑∑∑(D(BD + ∆B)F(i, QF)),

где индексы сумм пробегают значения

Q F(i) = 1, 2, …, NF,

а элементы вектора

B = (∆B(i))

находятся как

B(i) = QF(i) - 1 - R,

где радиус фильтра

R = (NF- 1) / 2.

Таким образом, фильтр рассчитывает взвешенную сумму значений скалярного поля из окрестности текущей точки BD радиусом R для всех точек окрестности, в которых определено значение скалярного поля. При этом для краевых точек BD, координаты которых

B D(i) ≤ R или BD(i) ≥ SD(i) - R,

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

G(i, BD) = 0.

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

Для вычисления градиента фильтр свертки, как правило, задает центральные разности. Тогда производная по направлению находится как

D(BD)/∂BD(i) = D(BD + eD(i)) - D(BD - eD(i)).

Вектор градиента направлен в сторону наискорейшего роста значений скалярного поля и будет перпендикулярен поверхностям равных значений. Наличие ненулевого градиента в точке скалярного поля, образованного совокупностью областей одинаковых значений, позволяет утверждать, что через данную точку проходит поверхность границы областей. Тем самым осуществляется сегментация воксельного пространства или выделение воксельных поверхностей по аналогии с нахождением контуров на растровом изображении [12]. Эти свойства позволяют использовать инвертированный нормированный вектор градиента как нормаль к поверхности равного значения скалярного поля в данной точке, необходимую для расчета затенения:

n = -||grad(D(BD))||.

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

n cp =∑∑∑(G(i, BD + ∆B)),

где индексы сумм пробегают значения

Q F(i) = 1, 2, …, NF.

Практическое значение градиента можно проиллюстрировать на простом примере его вычисления для ряда нулей и единиц, изображенного на рисунке 15 в первом ряду. Представляя объемное изображение составленным из таких полосок, можно наглядно увидеть принцип нахождения воксельных нормалей и поверхностей равного значения. Значение 0 скалярного поля обозначает, что эта точка находится в воздухе, а 1 – в теле. Применим простой одномерный фильтр F(1) = (-1, 0, 1), задающий центральную разность. Располагая середину фильтра в очередной точке, как показано на рисунке 15, и двигаясь слева направо, будем умножать значения ряда, покрытые фильтром, на его соответствующие коэффициенты и суммировать полученные значения. Сумму будем писать в результирующий изначально пустой ряд точно такого же размера, изображенный под исходным рядом на рисунке 15. Запись будем производить в точку, соответствующую точке первого ряда, находящейся в середине фильтра при его текущем положении. Выполнив данную операцию для всех точек, принимая во внимание то, что значения свертки для граничных элементов ряда равны нулю, во втором ряду получим искомый градиент. Как видно из рисунка 15, во втором ряду появились довольно «толстые», в два вокселя, границы перехода из 0 в 1, представленные 1, и из 1 в 0, это -1. Избавиться от утолщенной границы при необходимости можно умножением элементов первого ряда на элементы второго. Результат изображен на рисунке 15 в третьем ряду. Инвертируем полученные градиенты для получения нормалей. Представим, что базовый вектор eD этого одномерного пространства направлен вправо. Тогда 1 соответствует сонаправленному ему единичному вектору, а -1 – противоположно направленному единичному вектору. Вектора представлены на рисунке 15 в четвертом ряду. В итоге получен требуемый результат: нормали направлены из тела в воздух в точках границы.


Рисунок 15. Пример вычисления градиента.

Обработку больших воксельных моделей на графическом процессоре необходимо вести блоками, работа с которыми укладывается в отведенный на работу шейдера таймаут. Будем проводить вычисление градиента при помощи центральных разностей и его сглаживание путем усреднения последовательно для каждого из горизонтальных слоев скалярного поля вычислительными шейдерами. Шейдер представляет собой программу потока графического процессора для обработки элемента ресурса. Поток идентифицируется как элемент трехмерной матрицы при помощи входного параметра вычислительного шейдера – трехмерного вектора индексов потока в трехмерной матрице threadId. Размеры матрицы, а, следовательно, и количество одновременно исполняемых потоков определяется вызывающим приложением. Как правило, они согласуются с количеством элементов обрабатываемого ресурса. Элементом ресурса при этом являются вершины или тексели [9]. В листинге ниже приведен соответствующий HLSL код.

      float Slice;
float Neighbourhood;
Texture2DArray<float> VolumeSRV;
Texture2DArray<float4> GradientSRV;
RWTexture2DArray<float4> GradientUAV;

[numthreads(1, 1, 1)]
void CentralDifferencesCS(uint3 threadId : SV_DispatchThreadID)
{
    float3 unit[3] =
    {
        float3(1.0f, 0.0f, 0.0f),
        float3(0.0f, 1.0f, 0.0f),
        float3(0.0f, 0.0f, 1.0f)
    };
    float3 pos = float3(threadId.x, threadId.y, Slice);
    GradientUAV[pos] = float4
    (
        VolumeSRV[pos + unit[0]] - VolumeSRV[pos - unit[0]],
        VolumeSRV[pos + unit[1]] - VolumeSRV[pos - unit[1]],
        VolumeSRV[pos + unit[2]] - VolumeSRV[pos - unit[2]],
        1.0f
    );
}

[numthreads(1, 1, 1)]
void AverageCS(uint3 threadId : SV_DispatchThreadID)
{
    float3 pos = float3(threadId.x, threadId.y, Slice);
    float3 average = 0.0f;
    float3 d;
    for (d.x = -Neighbourhood; d.x <= Neighbourhood; d.x++)
    {
        for (d.y = -Neighbourhood; d.y <= Neighbourhood; d.y++)
        {
            for (d.z = -Neighbourhood; d.z <= Neighbourhood; d.z++)
            {
                average += GradientSRV[pos + d].xyz;
            }
        }
    }
    average = normalize(average);
    GradientUAV[pos] = float4(average, 1.0f);
}

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

В следующем листинге приведен процессорный код организации вычислений градиента.

      private Texture2D gradient;
        private ShaderResourceView gradientSRV;
        private UnorderedAccessView gradientUAV;

        privatevoid Gradient()
        {
            Texture2DDescription d = new Texture2DDescription()
            {
                ArraySize = volume.Description.ArraySize,
                BindFlags = BindFlags.ShaderResource |
                            BindFlags.UnorderedAccess,
                CpuAccessFlags = CpuAccessFlags.None,
                Format = Format.R8G8B8A8_SNorm,
                Height = volume.Description.Height,
                MipLevels = 1,
                OptionFlags = ResourceOptionFlags.None,
                SampleDescription = new SampleDescription(1, 0),
                Usage = ResourceUsage.Default,
                Width = volume.Description.Width
            };
            Texture2D rawGradient = new Texture2D(device, d);
            ShaderResourceView rawGradientSRV = new ShaderResourceView(
                device, rawGradient);
            UnorderedAccessView rawGradientUAV = new UnorderedAccessView(
                device, rawGradient);
            gradient = new Texture2D(device, d);
            gradientSRV = new ShaderResourceView(device, gradient);
            gradientUAV = new UnorderedAccessView(device, gradient);
            effect.GetVariableByName("VolumeSRV").AsShaderResource().
                SetResource(volumeSRV);
            effect.GetVariableByName("GradientUAV").
                AsUnorderedAccessView().Set(rawGradientUAV);
            for (int slice = 0;
                 slice < volume.Description.ArraySize;
                 slice++)
            {
                effect.GetVariableByName("Slice").AsScalar().Set(slice);
                effect.GetGroupByName("VolumeRendering").
                    GetTechniqueByName("GradientComputing").
                    GetPassByName("CentralDifferences").
                    Apply(device.ImmediateContext);
                device.ImmediateContext.Dispatch(
                    volume.Description.Width,
                    volume.Description.Height, 1);
                device.ImmediateContext.Flush();
            }
            effect.GetVariableByName("VolumeSRV").AsShaderResource().
                SetResource(null);
            UnorderedAccessView uav = null;
            effect.GetVariableByName("GradientUAV").
                AsUnorderedAccessView().Set(uav);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName("GradientComputing").
                GetPassByName("CentralDifferences").
                Apply(device.ImmediateContext);
            effect.GetVariableByName("GradientSRV").AsShaderResource().
                SetResource(rawGradientSRV);
            effect.GetVariableByName("GradientUAV").
                AsUnorderedAccessView().Set(gradientUAV);
            effect.GetVariableByName("Neighbourhood").AsScalar().Set(1);
            for (int slice = 0;
                 slice < volume.Description.ArraySize;
                 slice++)
            {
                effect.GetVariableByName("Slice").AsScalar().Set(slice);
                effect.GetGroupByName("VolumeRendering").
                    GetTechniqueByName("GradientComputing").
                    GetPassByName("Average").
                    Apply(device.ImmediateContext);
                device.ImmediateContext.Dispatch(
                    volume.Description.Width,
                    volume.Description.Height, 1);
                device.ImmediateContext.Flush();
            }
            effect.GetVariableByName("GradientSRV").AsShaderResource().
                SetResource(null);
            effect.GetVariableByName("GradientUAV").
                AsUnorderedAccessView().Set(uav);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName("GradientComputing").
                GetPassByName("Average").Apply(device.ImmediateContext);
            rawGradientUAV.Dispose();
            rawGradientSRV.Dispose();
            rawGradient.Dispose();
        }

В листинге ниже приведено описание состояния графического конвейера для выполнения данных шейдеров.

      technique11 GradientComputing
    {
        pass CentralDifferences
        {
            SetBlendState(BlendingDisable,
                          float4(0.0f, 0.0f, 0.0f, 0.0f),
                          0xFFFFFFFF);
            SetDepthStencilState(DepthTestDisable, 0);
            SetRasterizerState(NoneCulling);
            SetRenderTargets(NULL, NULL);
            SetVertexShader(NULL);
            SetHullShader(NULL);
            SetDomainShader(NULL);
            SetGeometryShader(NULL);
            SetPixelShader(NULL);
            SetComputeShader(CompileShader(cs_5_0,
                                           CentralDifferencesCS()));
        }
        pass Average
        {
            SetBlendState(BlendingDisable,
                          float4(0.0f, 0.0f, 0.0f, 0.0f),
                          0xFFFFFFFF);
            SetDepthStencilState(DepthTestDisable, 0);
            SetRasterizerState(NoneCulling);
            SetRenderTargets(NULL, NULL);
            SetVertexShader(NULL);
            SetHullShader(NULL);
            SetDomainShader(NULL);
            SetGeometryShader(NULL);
            SetPixelShader(NULL);
            SetComputeShader(CompileShader(cs_5_0, AverageCS()));
        }
    }

Градиент может вычисляться непосредственно в пиксельном шейдере при рендеринге. Ниже приведен соответствующий код.

      float3 x = float3
(
    planeIndex[0] / size[0],
    planeIndex[1] / size[1],
    planeIndex[2]
);
float3 normal = normalize(float3
(
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        - 1.0f / size[0],
        0.0f,
        0.0f
    )) –
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        1.0f / size[0],
        0.0f,
        0.0f
    )),
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        0.0f,
        - 1.0f / size[1],
        0.0f
    )) –
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        0.0f,
        1.0f / size[1],
        0.0f
    )),
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        0.0f,
        0.0f,
        -1.0f
    )) –
    VolumeSRV.SampleLevel(PointBorderSampling, x, float3
    (
        0.0f,
        0.0f,
        1.0f
    ))
));

Таким образом, в работе применяется три вида затенения по способу вычисления нормали: с нормалями, предварительно сглаженными при помощи вычислительных шейдеров (PreSmoothedShanding), с вычислением нормалей при рейкастинге в пиксельном шейдере (SmoothedShanding) и плоское (FlatShanding). Данные способы оформлены в виде трех методов в группе прямого объемного рендеринга создаваемого эффекта. В листинге ниже приведен простой код переключения пользователем с клавиатуры в ходе работы приложения данных методов, а также используемых текстур. Кроме того, код содержит сохранение пользователем текущего кадра рендеринга.

      private
      void RenderFormKeyPress(object sender,
                                       KeyPressEventArgs e)
        {
            if (e.KeyChar == '1') technique = "PreSmoothedShading";
            elseif (e.KeyChar == '2') technique = "SmoothedShading";
            elseif (e.KeyChar == '3') technique = "FlatShading";
            elseif (e.KeyChar == 'c') colored = !colored;
            elseif (e.KeyChar == 's')
            {
                string name = DateTime.Now.ToString().Replace('.', '-').
                                                      Replace(':', '-');
                Texture2D.ToFile<Texture2D>(device.ImmediateContext,
                    renderTarget, ImageFileFormat.Png, name + ".png");
                Texture2D.ToFile<Texture2D>(device.ImmediateContext,
                    rayHitBox, ImageFileFormat.Png, name + "-hit.png");
            }
        }

Рендеринг

Приведем реализацию метода затенения, использующего предварительно сглаженные нормали. Метод состоит из двух проходов. Первый вычисляет точки пересечения видовых лучей с задними гранями единичного куба объемного изображения, сохраняя их в отдельный буфер, как было описано выше. Второй проход использует тот же вершинный шейдер, и в своем пиксельном шейдере получает точки пересечения видовых лучей с передними гранями куба. Между полученными точкам пересечения в пиксельном шейдере второго прохода начинается рейкастинг с прослеживанием вокселей. Для очередного отобранного вокселя считывается его нормаль. В случае, если она имеет ненулевую длину, по коду отобранного вокселя и точке пересечения с его гранью происходит чтение из текстур воксельных материалов, затенение и смешивание. Условиями прекращения рейкастинга, рассмотренными ранее, являются пересечение с непрозрачной гранью, выход за пределы единичного куба объемного изображения или отбор максимального количества вокселей вдоль луча. Остальные методы реализуются аналогично. Однако следует обратить внимание, что при плоском затенении расчеты выполняются не в зависимости от длины нормали, а в том случае, если отбираемый воксель содержит непрозрачный материал. Для удобства определения в коде используемые способы чтения текстур и состояния графического конвейера вынесены в отдельные *.hlsl файлы.

      #include
      "GraphicsPipelineStates.hlsl"
      #include
      "TextureSamplers.hlsl"
      float3 Eye;
float3 LightDirection;
float4 LightColor;
float MaxTraversedVoxels;
Texture2D<float4> RayHitBoxSRV;
Texture2DArray<float4> AmbientSRV;
Texture2DArray<float4> DiffuseSRV;
Texture2DArray<float4> SpecularSRV;
Texture1D<float> SpecularPowerSRV;
DepthStencilView DepthStencil;
RenderTargetView RenderTarget;

struct Texel
{
    float4 Color : SV_TARGET0;
    float Depth : SV_DEPTH;
};

Texel PreSmoothedShadingPS(Vertex input)
{
    Texel output;
    output.Color = float4(0.0f, 0.0f, 0.0f, 0.0f);
    output.Depth = 1.0f;
    float2 texCoord[3] =
    {
        float2(1.0f, 2.0f),
        float2(0.0f, 2.0f),
        float2(0.0f, 1.0f)
    };
    float size[3];
    VolumeSRV.GetDimensions(size[0], size[1], size[2]);
    size[0] -= 1.0f;
    size[1] -= 1.0f;
    size[2] -= 1.0f;
    float2 uv = float2
    (
        0.5f * input.RayHitScreen.x / input.RayHitScreen.w + 0.5f,
        -0.5f * input.RayHitScreen.y / input.RayHitScreen.w + 0.5f
    );
    float4 rayHitBox = RayHitBoxSRV.SampleLevel(LinearClampSampling, 
                                                uv, 0, 0);
    float3 begin = rayHitBox.xyz;
    if (rayHitBox.a == 0.0f) begin = Eye;
    float3 look = input.RayHitBox - begin;
    float3 lookDirection = normalize(look);
    float lookSign[3] =
    {
        sign(look.x),
        sign(look.y),
        sign(look.z)
    };
    float planeDistance[3] = { 0.0f, 0.0f, 0.0f };
    float plane = 0.0f;
    float planeIndex[3] = 
    {
        floor(begin.x * size[0]),
        floor(begin.y * size[1]),
        floor(begin.z * size[2])
    };
    float planeCount[3] = { 0.0f, 0.0f, 0.0f };
    while (planeCount[0] < MaxTraversedVoxels &&
           planeCount[1] < MaxTraversedVoxels &&
           planeCount[2] < MaxTraversedVoxels)
    {
        float3 volCoord = float3(planeIndex[0] / size[0],
                                 planeIndex[1] / size[1],
                                 planeIndex[2]);
        float3 normal = GradientSRV.SampleLevel(LinearBorderSampling,
                                                volCoord, 0).xyz;
        if (length(normal) > 0.0f)
        {
            float voxel = VolumeSRV.SampleLevel(PointBorderSampling,
                                                volCoord, 0);
            float3 intersection = begin + planeDistance[plane] * look;
            float interCoord[3] =
            {
                intersection.x,
                intersection.y,
                intersection.z
            };
            float3 uvw = float3
            (
                abs(interCoord[texCoord[plane].x] *
                    size[texCoord[plane].x] -
                    planeIndex[texCoord[plane].x]),
                abs(interCoord[texCoord[plane].y] *
                    size[texCoord[plane].y] -
                    planeIndex[texCoord[plane].y]),
                voxel * 255.0f
            );
            float4 color = AmbientSRV.SampleLevel(LinearClampSampling,
                                                  uvw, 0);
            float intensity = dot(normal, LightDirection);
            if (intensity > 0.0f)
            {
                float4 diffuse = DiffuseSRV.SampleLevel(
                    LinearClampSampling, uvw, 0) * intensity;
                float3 reflection = normalize(2.0f * intensity * normal -
                                              LightDirection);
                float4 specular = SpecularSRV.SampleLevel(
                    LinearClampSampling, uvw, 0) *
                    pow(abs(dot(reflection, lookDirection)),
                        SpecularPowerSRV.SampleLevel(
                            LinearClampSampling, voxel, 0));
                color = saturate(color + diffuse + specular);
            }
            if (color.a > 0.0f)
            {
                output.Depth = mul(float4(intersection, 1.0f),
                                   WorldViewProj).z;
                color *= LightColor;
                float a = output.Color.a;
                output.Color.a = output.Color.a +
                    color.a * (1.0f - output.Color.a);
                if (output.Color.a == 0.0f)
                    output.Color.grb = float3(0.0f, 0.0f, 0.0f);
                else output.Color.rgb = (output.Color.rgb * a +
                    color.rgb * color.a * (1.0f - a)) / output.Color.a;
                if (output.Color.a > 0.95f) break;
            }
        }
        planeDistance[0] = 1.0f / 0.0f;
        planeDistance[1] = 1.0f / 0.0f;
        planeDistance[2] = 1.0f / 0.0f;
        if (look.x < 0.0f) planeDistance[0] = (planeIndex[0] / size[0] -
                                               begin.x) / look.x;
        if (look.x > 0.0f) planeDistance[0] = ((planeIndex[0] + 1.0f) /
                                size[0] - begin.x) / look.x;
        if (look.y < 0.0f) planeDistance[1] = (planeIndex[1] / size[1]
                                - begin.y) / look.y;
        if (look.y > 0.0f) planeDistance[1] = ((planeIndex[1] + 1.0f) /
                                size[1] - begin.y) / look.y;
        if (look.z < 0.0f) planeDistance[2] = (planeIndex[2] / size[2] -
                                begin.z) / look.z;
        if (look.z > 0.0f) planeDistance[2] = ((planeIndex[2] + 1.0f) /
                                size[2] - begin.z) / look.z;
        plane = planeDistance[0] < planeDistance[1] ?
                    (planeDistance[0] < planeDistance[2] ? 0.0f : 2.0f) :
                    (planeDistance[1] < planeDistance[2] ? 1.0f : 2.0f);
        if (planeDistance[plane] < 0.0f ||
            planeDistance[plane] > 1.0f) break;
        planeIndex[plane] += lookSign[plane];
        planeCount[plane]++;
    }
    return output;
}

Приведем описание состояний графического конвейера для выполнения эффекта.

      fxgroup VolumeRendering
{
    technique11 PreSmoothedShading
    {
        pass RayHitBox
        {
            SetBlendState(AlphaBlending,
                          float4(0.0f, 0.0f, 0.0f, 0.0f),
                          0xFFFFFFFF);
            SetDepthStencilState(DepthTestDisable, 0);
            SetRasterizerState(FrontCulling);
            SetRenderTargets(RenderTarget, NULL);
            SetVertexShader(CompileShader(vs_5_0, VS()));
            SetHullShader(NULL);
            SetDomainShader(NULL);
            SetGeometryShader(NULL);
            SetPixelShader(CompileShader(ps_5_0, RayHitBoxPS()));
            SetComputeShader(NULL);
        }
        pass Render
        {
            SetBlendState(AlphaBlending,
                          float4(0.0f, 0.0f, 0.0f, 0.0f),
                          0xFFFFFFFF);
            SetDepthStencilState(DepthTestEnable, 0);
            SetRasterizerState(BackCulling);
            SetRenderTargets(RenderTarget, DepthStencil);
            SetVertexShader(CompileShader(vs_5_0, VS()));
            SetHullShader(NULL);
            SetDomainShader(NULL);
            SetGeometryShader(NULL);
            SetPixelShader(CompileShader(ps_5_0,
                                         PreSmoothedShadingPS()));
            SetComputeShader(NULL);
        }
    }
}

Рассмотрим код, ответственный за управление рендерингом объемного изображения в программе при помощи созданного эффекта. Обычно визуализации подлежит не одна, а несколько моделей. Как следствие растеризуемые буферы вершин и индексов постоянно переключаются. Поэтому нужно быть уверенным в том, что перед отрисовкой модели на графический конвейер отправлена нужная геометрия, без хотя бы одной вершины которой не удастся запустить цепочку шейдеров (за исключением вычислительного). Это же относится и к различным используемым ресурсам. По этой причине код установки ресурсов для отдельной модели выполняется перед каждой ее отрисовкой. При визуализации единственной модели это, конечно, можно проделать один раз.

      private
      void Run()
        {
            RenderLoop.Run(renderForm, () =>
            {
                Render();
                CalcFPS();
            }
        }

        staticvoid Render()
        {
            SetGeometry();
            Clear();
            RayHitBox();
            SetEffect();
            device.ImmediateContext.DrawIndexed(36, 0, 0);
            UnsetEffect();
            swapChain.Present(0, PresentFlags.None);
        }

        staticvoid SetGeometry()
        {
            device.ImmediateContext.InputAssembler.
                InputLayout = inputLayout;
            device.ImmediateContext.InputAssembler.
                PrimitiveTopology = PrimitiveTopology.TriangleList;
            device.ImmediateContext.InputAssembler.
                SetVertexBuffers(0, new VertexBufferBinding(
                    vertexBuffer, 12, 0));
            device.ImmediateContext.InputAssembler.
                SetIndexBuffer(indexBuffer, Format.R32_UInt, 0);
        }

        staticvoid Clear()
        {
            device.ImmediateContext.ClearRenderTargetView(rayHitBoxRTV,
                Colors.Transparent);
            device.ImmediateContext.ClearRenderTargetView(
                renderTargetView, background);
            device.ImmediateContext.ClearDepthStencilView(
                depthStencilView, DepthStencilClearFlags.Depth, 1.0f, 0);
        }

        staticvoid RayHitBox()
        {
            effect.GetVariableByName("WorldViewProj").AsMatrix().
                SetMatrix(worldViewProj);
            effect.GetVariableByName("RenderTarget").
                AsRenderTargetView().SetRenderTarget(rayHitBoxRTV);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName(technique).
                GetPassByName("RayHitBox").
                Apply(device.ImmediateContext);
            device.ImmediateContext.DrawIndexed(36, 0, 0);
            effect.GetVariableByName("RenderTarget").
                AsRenderTargetView().SetRenderTarget(null);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName(technique).
                GetPassByName("RayHitBox").
                Apply(device.ImmediateContext);
        }

        staticvoid SetEffect()
        {
            effect.GetVariableByName("WorldViewProj").AsMatrix().
                SetMatrix(worldViewProj);
            effect.GetVariableByName("Eye").AsVector().Set(eye);
            effect.GetVariableByName("LightDirection").AsVector().
                Set(Vector3.Normalize(new Vector3(3.0f, 2.0f, 1.0f)));
            effect.GetVariableByName("LightColor").AsVector().
                Set(Colors.White);
            effect.GetVariableByName("MaxTraversedVoxels").AsScalar().
                Set(1024.0f);
            effect.GetVariableByName("VolumeSRV").AsShaderResource().
                SetResource(volumeSRV);
            if (technique == "PreSmoothedShading")
                effect.GetVariableByName("GradientSRV").
                    AsShaderResource().SetResource(gradientSRV);
            effect.GetVariableByName("RayHitBoxSRV").AsShaderResource().
                SetResource(rayHitBoxSRV);
            if (colored)
            {
                effect.GetVariableByName("AmbientSRV").
                    AsShaderResource().SetResource(ambientColorSRV);
                effect.GetVariableByName("DiffuseSRV").
                    AsShaderResource().SetResource(diffuseColorSRV);
                effect.GetVariableByName("SpecularSRV").
                    AsShaderResource().SetResource(specularColorSRV);
            }
            else
            {
                effect.GetVariableByName("AmbientSRV").
                    AsShaderResource().SetResource(ambientSRV);
                effect.GetVariableByName("DiffuseSRV").
                    AsShaderResource().SetResource(diffuseSRV);
                effect.GetVariableByName("SpecularSRV").
                    AsShaderResource().SetResource(specularSRV);
            }
            effect.GetVariableByName("SpecularPowerSRV").
                AsShaderResource().SetResource(specularPowerSRV);
            effect.GetVariableByName("RenderTarget").
                AsRenderTargetView().SetRenderTarget(renderTargetView);
            effect.GetVariableByName("DepthStencil").
                AsDepthStencilView().SetDepthStencil(depthStencilView);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName(technique).
                GetPassByName("Render").
                Apply(device.ImmediateContext);
        }

        staticvoid UnsetEffect()
        {
            effect.GetVariableByName("VolumeSRV").AsShaderResource().
                SetResource(null);
            if (technique == "PreSmoothedShading")
                effect.GetVariableByName("GradientSRV").
                    AsShaderResource().SetResource(null);
            effect.GetVariableByName("RayHitBoxSRV").AsShaderResource().
                SetResource(null);
            effect.GetVariableByName("AmbientSRV").AsShaderResource().
                SetResource(null);
            effect.GetVariableByName("DiffuseSRV").AsShaderResource().
                SetResource(null);
            effect.GetVariableByName("SpecularSRV").AsShaderResource().
                SetResource(null);
            effect.GetVariableByName("SpecularPowerSRV").
                AsShaderResource().SetResource(null);
            effect.GetVariableByName("RenderTarget").
                AsRenderTargetView().SetRenderTarget(null);
            effect.GetVariableByName("DepthStencil").
                AsDepthStencilView().SetDepthStencil(null);
            effect.GetGroupByName("VolumeRendering").
                GetTechniqueByName(technique).
                GetPassByName("Render").
                Apply(device.ImmediateContext);
        }

        static DateTime start;
        staticint frameCount;
        staticdouble fps;

        staticvoid CalcFPS()
        {
            frameCount++;
            DateTime now = DateTime.Now;
            TimeSpan timeSpan = now - start;
            if (timeSpan.TotalMilliseconds > 500)
            {
                fps = frameCount / timeSpan.TotalSeconds;
                frameCount = 0;
                start = now;
            }
            statusBarLabel.Text = string.Format(
                "Объемное изображение {0} x {1} x {2} @ 8 bpv " +
                "Метод рендеринга: {3} " +
                "Размер кадра: {4} x {5} " +
                "FPS: {6:F2} " + 
                "1,2,3 - переключить метод, "+
                "c - цвет|текстура, " + 
                "s - сохранить буферы.",
                volume.Description.Width,
                volume.Description.Height,
                volume.Description.ArraySize,
                technique,
                renderForm.ClientSize.Width,
                renderForm.ClientSize.Height,
                fps);
        }
    }
}

Результаты работы программы

Приведем изображения, которые были получены в ходе работы программы при рендеринге тестовой модели карьера отработки месторождения твердых полезных ископаемых. В карьере представлено три типа обобщенных горных пород: рыхлые, которым назначена текстура с травой и зеленый цвет, скальные породы – текстура земли и красный цвет, и отдельно выделенный среди скальных пород мрамор – текстура мрамора и синий цвет. На рисунке 16 приведен обзорный текстурированный вид тестовой модели сверху, рисунок 17 – тот же вид с использованием цветов, но с измененным направлением света (строго сверху).


Рисунок 16.


Рисунок 17.

На рисунке 18 приведен вид модели сбоку. На рисунке 19 – соответствующее этому положению содержимое буфера пересечений лучей с передними гранями куба. Координаты точек лежат в единичном интервале, поэтому визуализированы цветом в формате RGB. В частности, координате X соответствует красный канал R, координате Y – зеленый G, а Z – голубой B. Таким образом, например, точка с координатами (1, 0, 0) соответствует красному цвету, (0, 1, 0) – зеленому, а (0, 0, 1) – голубому. Цвет прочих точек получается в результате смешивания этих компонентов.


Рисунок 18.


Рисунок 19.

На рисунке 20 приведен вид при подходе к краю борта карьера.


Рисунок 20.

На рисунке 21 – вид с откоса борта карьера вниз на дно.


Рисунок 21.

На рисунке 22 – мраморная площадка с плоским затенением.


Рисунок 22.

На рисунках 23, 24 и 25 демонстрируется разница в затенении в зависимости от используемого метода определения нормали.


Рисунок 23. Сглаженное затенение с предвычисленным градиентом.


Рисунок 24. Сглаженное затенение с расчетом градиента при бросании луча.


Рисунок 25. Плоское затенение.

На рисунке 26 показаны блики на зеркальной поверхности мрамора.


Рисунок 26.

Закрывает обзор панорама борта карьера с рабочей площадки на рисунке 27.


Рисунок 27.

Таблица 1. Конфигурации тестовых стендов.

Конфигурация

А

Б

Материнская плата

ASRock X58 Extreme

GA-890FXA-UD5

Процессор

Core i7 920 (2,8 ГГц)

Phenom II X6 1090T (3,2 ГГц)

Память

PC3-10666 Hynix (4 ГБ)

PC3-10666 Elixir (16 ГБ)

Видеокарта

GTX 580 (GV-N580SO-15I)

HD 5870 (GV-R587OC-1GD)

Жесткий диск

Corsair CSSD-F60GB2 (60 ГБ)

OCZ-VERTEX2 (60 ГБ)

Блок питания

Thermaltake W0131 (850 Вт)

TRX-1000MPCEU (1000 Вт)

Операционная система

Windows 7 Home Premium x64

Server 2008 R2 Standard

Таблица 2. Параметры тестовой модели.

Параметр

Значение

Размеры

1024 x 1024 x 175 вокселей

Разрядность вокселя

8 бит, 256 значений

Размеры текстур

128 x 128 текселей

Формат текселя

RGBA, 32 бита, 8 бит на канал

Таблица 3. Результаты тестирования.

Конфигурация

А

Б

Среднее время расчета градиента, сек

18

30

Средняя частота кадров в секунду

40

13

Заключение

Отметим основные преимущества разработанного кода:

Перечислим имеющиеся недостатки:

Приведем планируемые улучшения:

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

Список литературы

  1. Арсланов Д. М. Метод воксельной растеризации и обработки: [Электронный ресурс] // RSDN Magazine. 2011. № 3. URL: http://www.rsdn.ru/article/alg/03-12-voxel.xml. (Дата обращения: 09.07.2012).
  2. Ламот, А. Программирование трехмерных игр для Windows. Советы профессионала по трехмерной графике и растеризации / Андре Ламот; пер. с англ. Р. Имамутдинова. – М.: Изд-во Вильямс, 2006. – 1424 с.
  3. Семенихин А. Сравнительный анализ методов интерактивной триангуляции сеточных функций: [Электронный ресурс] // Компьютерная графика и мультимедиа. 2004. № 2 (2). URL: http://cgm.computergraphics.ru/content/view/63. (Дата обращения: 09.07.2012).
  4. Скворцов, А. В. Триангуляция Делоне и е применение / А. В. Скворцов. – Томск: Изд-во Том. ун-та, 2002. – 128 с.
  5. .NET Framework 4: [Электронный ресурс] // MSDN Library. 2012. URL: http://msdn.microsoft.com/en-us/library/w0x726c2. (Дата обращения: 09.07.2012).
  6. Crane, K. Chapter 30. Real-Time Simulation and Rendering of 3D Fluids: [Электронный ресурс] / Keenan Crane, Ignacio Llamas, Sarah Tariq // GPU Gems 3. 2009. http://developer.nvidia.com/node/187. (Дата обращения: 09.07.2012).
  7. Direct3D: [Электронный ресурс] // MSDN Library. 2012. URL: http://msdn.microsoft.com/en-us/library/hh309466(v=vs.85). (Дата обращения: 09.07.2012).
  8. Engel, K. Real-Time Volume Graphics / Klaus Engel, Markus Hadwiger, Joe M. Kniss, Christof Rezk-Salama, Daniel Weiskopf. – Wellesley: A K Peters, Ltd., 2006. – 488 p.
  9. Green S. DirectCompute Programming Guide: [Электронный ресурс] / Simon Green // NVIDIA GPU Computing Documentation. 2010. URL: http://developer.download.nvidia.com/compute/DevZone/docs/html/DirectCompute/doc/DirectCompute_Programming_Guide.pdf. (Дата обращения: 09.07.2012).
  10. Ikits, M. Chapter 39. Volume Rendering Techniques: [Электронный ресурс] / Milan Ikits, Joe Kniss, Aaron Lefohn, Charles Hansen // GPU Gems. 2007. http://http.developer.nvidia.com/GPUGems/gpugems_ch39.html. (Дата обращения: 09.07.2012).
  11. Moller, T. Real-Time Rendering / Tomas Akenine-Moller, Eric Haines, Naty Hoffman. – 3rd ed. – Wellesley: A K Peters, Ltd., 2008. – 1027 p.
  12. NI Vision Concepts Manual: [Электронный ресурс] // National Instruments. 2005. URL: http://www.ni.com/pdf/manuals/372916e.pdf. (Дата обращения: 09.07.2012).
  13. SharpDX – Manaed DirectX: [Электронный ресурс]. 2012. URL: http://sharpdx.org. (Дата обращения: 09.07.2012).


© Арсланов Д. М. 2012
    Сообщений 0    Оценка 0        Оценить