Процедурное построение клеточных текстур

Использование шумовой функции Перлина noise (и полученных с ее помощью других функций) позволило построить широкий класс процедурных текстур (таких как, облака, мрамор, лава, огонь и т.п.).

Однако довольно часто встречается потребность в текстурах, которые должны иметь ярко-выраженный клеточный (cellular) характер. К числу таких текстур относится большое количество так называемых органических текстур, т.е. текстур, используемых для моделирования поверхностей живых существ.

Очень простой подход к созданию таких текстур был предложен Стивеном Уорли (Steven Worley) - одним из авторов книги "Texturing & Modelling. A procedural approach".

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

Разобьем прямоугольную текстуру (размером width на height текселов) на n1*n2 одинаковых ячейки. В каждой из них выберем некоторое количество случайных точек, равномерно распределенных в пределах соответствующей ячейки.

Рис 1. Распределение точек по ячейкам.

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

Проще всего для этого использовать распределение Пуассона - если через lambda обозначить среднюю плотность точек в ячейке, то вероятность того, что в ячейке окажется ровно m точек задается следующей формулой:

Poisson distribution equation

Поэтому можно перед генерацией точек по заданной средней плотности точек рассчитать вероятности того, что в ячейку попадет m точек для всех m=0,1,2,....

Для этого достаточно построить массив Sn сумм вероятностей для m=1,...,n. Фактически Sn является вероятностью того, что в ячейку попадет не менее Sn точек.

Тогда для каждой ячейки для получения числа точек m достаточно взять случайное число r, равномерно распределенное в отрезке [0,1] и найти первый такой номер m, что Sm >= r. Этот номер и будет числом точек в данной ячейке.

Построив таким образом массив случайных точек на в прямоугольнике [0,width-1]*[0,height-1], на нем можно определить функцию F1(x,y), как ближайшее расстояние от точки (x,y) до массива случайных точек.

Обычно на этом прямоугольнике вводят нормированные координаты, т.е. считают его совпадающим с единичным квадратом.

F1 func

Рис 2. График функции F1(x,y).

Аналогичным образом можно определить функцию F2(x,y) как второе ближайшее расстояние от (x,y) до массива случайных точек, функцию F3(x,y) как третье ближайшее расстояние и т.д.

F2 func F3 func

Рис 3. Графики функции F2(x,y) и F3(x,y).

Ясно, что всегда выполняется следующее неравенство

comparison of Fs

В большинстве случаев желательно, чтобы построенные таким образом функции (текстуры) F1, F2, F3 и т.д. были периодическими.

Периодичность для них означает, что значения на левом крае должны совпадать с соответствующими значениями на правом крае, а значения на верхним крае - совпадать со значениями на нижнем крае.

Этого легко добиться путем незначительного изменения функции, вычисляющей расстояние между двумя точками - в случае когда расстояние (по х или у) превышает половину соответствующего размера текстуры, то производится коррекция расстояния:

def distance ( p1, p2 ):                # L2 distance function
    dx = abs ( p1 [0] - p2 [0] )
    dy = abs ( p1 [1] - p2 [1] )

    if dx > 0.5:
        dx = 1 - dx

    if dy > 0.5:
        dy = 1 - dy

    d = math.sqrt ( dx*dx + dy*dy ) / maxDist

    if d > 1:
        d = 1

    return d

Обычно бывает удобным ограничить диапазон изменения расстояния некоторым отрезком [0,maxDist].

Клетки, для которых строились случайные точки, можно также использовать и для оптимизации вычисления функций F1, F2, F3 и др.

Для этого обратим внимание, что в большинстве случаев (кроме случая очень малой плотности точек), точки, на которых реализуются значения функций F1, F2 и F3, лежат либо в той же самой клетке, где и точка (x,y), или в одной из соседних с ней клеток.

Это обстоятельство позволяет значительно сократить перебор точек для вычисления функций F1, F2 и F3.

Именно этот подход и реализован в приводимой ниже программе на языке Python, вычисляющей значения функций F1, F2 и F3 и записывающих их в RGB-каналы текстуры.

import math, random, PIL, Image

width       = 256               # image size
height      = width
cellSize    = 8                 # 8 cells per width(height)
density     = 4.0               # point density
maxDist     = 0.11
k1          = 1.0 / float ( cellSize )
k2          = 1.0 / float ( cellSize )

                                # compute probabilities for verious 
                                # numbers of points
pointProbs  = []
mFact       = 1.0               # m!
lPower      = 1.0               # pow ( density, m )
s           = 0.0

for m in range ( 10 ):
    p      = lPower * math.exp ( -density ) / mFact
    s      = s + p
    mFact  = mFact * (m + 1)
    lPower = lPower * density
    pointProbs.append ( s )

def	numPointsForCell ():
    r = random.random ();

    for i in range ( 10 ):
        if r < pointProbs [i]:
            return i

    return i

def distance ( p1, p2 ):        # L2 distance function
    dx = abs ( p1 [0] - p2 [0] )
    dy = abs ( p1 [1] - p2 [1] )

    if dx > 0.5:
        dx = 1 - dx

    if dy > 0.5:
        dy = 1 - dy

    d = math.sqrt ( dx*dx + dy*dy ) / maxDist

    if d > 1:
        d = 1

    return d

im     = Image.new ( "RGB", (width, height) )
points = []

                                # generate random points
cells = []
for i1 in range ( cellSize ):
    cells.append ( [] )
    for i2 in range ( cellSize ):
        cells [i1].append ( [] )
        x1        = i1 * k1
        y1        = i2 * k2
        numPoints = numPointsForCell ()

        for i3 in range ( numPoints ):
            x = x1 + k1 * random.random ()
            y = y1 + k2 * random.random ()
            points.append ( ( x, y ) )
            cells [i1][i2].append ( ( x, y ) )

                                # create textures F1, F2, F3, F4
for x in range ( width ):
    for y in range ( height ):
        d  = []
        pt = ( float ( x ) / float ( width ), float ( y ) / float ( height ) )
                                     # cell of this point
        i1 = (x * cellSize) / width
        j1 = (y * cellSize) / height

        cellList = [(i1, j1)]        # create candidate list of cells

        for i in range ( -1, 2 ):    # add every neighbouring cell
            for j in range ( -1, 2 ):
                i2 = i1 + i
                j2 = j1 + j

                if i2 < 0:
                    i2 = cellSize + i2

                if i2 >= cellSize:
                    i2 = i2 - cellSize

                if j2 < 0:
                    j2 = cellSize + j2

                if j2 >= cellSize:
                    j2 = j2 - cellSize

                cellList.append ( ( i2, j2 ) )

        for (i,j) in cellList:
            for p in cells [i][j]:
                d.append (  distance ( p, pt ) )

        d.sort ()

                                # now take four closest distances and map to colors
                                # excluding duplicates
        fs = []
        j  = 0

        for i in range ( 4 ):
            if j >= len ( d ):
                j = len ( d ) - 1

            v = d [j]
            j = j + 1

            fs.append ( v )
            while j < len ( d ) and d [j] == v:
                j = j + 1

        red   = int ( 255 * fs [0] + 0.5 )
        green = int ( 255 * fs [1] + 0.5 )
        blue  = int ( 255 * fs [2] + 0.5 )

        im.putpixel ( (x, y), (red, green, blue) )

im.save ( "celltex.png", "png" )

Обратите внимание, что данный код использует библиотеку PIL для работы с изображениями.

В шейдерах можно использовать как сами базисные функции F1, F2, F3, так и различные их комбинации.

На следующих рисунках приводятся различные варианты процедурных текстур, получаемых при помощи базисных функций F1, F2 и F3.

sqr(1-F1) clamp(scale*(F2-F1-bias))

2*F3-F2-F1 F1+F2-F1*F2

sqr(F1*F2) fractal F1

Рис 4. Варианты комбинирования базисных функций.

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

На следующем рисунке функция pow(1-F1,2) используется как для определения цвета, так и для отклонения карты нормалей.

cellular bumpmapped torus screenshot

Рис 5. Пример использования клеточной текстуры.

Использованный при этом фрагментный шейдер на GLSL приводится на следующем листинге.

varying vec3 lt;
varying	vec3 ht;

uniform sampler2D cellMap;

const vec3  one = vec3 ( 1.0 );

vec2 dcell ( in vec2 tex )
{
    float vx1 = texture2D ( cellMap, tex + vec2 ( 0.01, 0.0 ) ).x;
    float vx2 = texture2D ( cellMap, tex - vec2 ( 0.01, 0.0 ) ).x;
    float vy1 = texture2D ( cellMap, tex + vec2 ( 0.0,  0.01 ) ).x;
    float vy2 = texture2D ( cellMap, tex - vec2 ( 0.0,  0.01 ) ).x;
    vec2  res = 4.0*vec2 ( vx1*vx1 - vx2*vx2, vy1*vy1 - vy2*vy2 );

    return res;
}

void main(void)
{
    vec2  tex  = vec2 ( 1.0, 2.0 )*gl_TexCoord [0].xy;
    vec3  cel  = texture2D ( cellMap, tex ).xyz;
    vec3  cel2 = 0.5*texture2D ( cellMap, 2.0*tex ).xyz;

    cel += cel2;

    vec2  dn      = dcell ( tex );
    vec3  n       = vec3 ( 0.0, 0.0, 1.0 );
    vec3  t       = vec3 ( 1.0, 0.0, 0.0 );
    vec3  b       = vec3 ( 0.0, 1.0, 0.0 );
    vec3  nn      = normalize ( n + dn.x * t + dn.y * b );
    vec3  l2      = normalize ( lt );
    vec3  h2      = normalize ( ht );
    float diffuse = 0.4 + max ( 0.0, dot ( nn, l2 ) );
    float spec    = pow ( max ( 0.0, dot ( nn, h2 ) ), 30.0 );

    gl_FragColor = pow ( 1.0-cel.x, 2.0 )*diffuse * vec4 ( 1.0, 0.0, 0.0, 1.0 ) + 
	               vec4 ( 1.0, 1.0, 1.0, 1.0 ) * spec;
}

За счет комбинирования шумовой функции и базисных функций F1, F2 и F3 можно создавать анимированные, как бы "дышащие" текстуры (см. рис 6.).

another torus

Рис 6. Пример анимированной текстуры, использующей как базисные функции F1, F2 и F3, так и шумовую функцию noise.

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

varying vec3 lt;
varying vec3 ht;
varying vec3 p;

uniform sampler3D noiseMap;
uniform sampler2D cellMap;
uniform float     time;

const float freq  = 2.071935;
const float freq2 = freq * freq;
const vec3  one   = vec3 ( 1.0 );

float f ( const in vec2 tex )
{
    float t = texture2D ( cellMap, tex ).x;

    return t*t;
}

vec3    df ( const in vec2 p )
{
    const float dx = 0.01;

    float fx = f ( p + vec2 ( dx, 0.0 ) ) - f ( p - vec2 ( dx, 0.0 ) ) / (2.0*dx);
    float fy = f ( p + vec2 ( 0.0, dx ) ) - f ( p - vec2 ( 0.0, dx ) ) / (2.0*dx);

    return vec3 ( fx, fy, 0.0 );
}

void main(void)
{
    vec3  ns1     = texture3D ( noiseMap, p + 4.0*time * vec3 ( 0.1, 0.0831, 0.119 ) ).xyz;
    vec2  tex     = vec2 ( 1.0, 2.0 )*gl_TexCoord [0].xy + 0.02*ns1.xy;
    float d1      = abs ( f ( tex + vec2 ( 0.01, 0.0 ) ) - f ( tex ) );
    float d2      = abs ( f ( tex + vec2 ( 0.0, 0.01 ) ) - f ( tex ) );
    vec3  n       = vec3 ( 0.0, 0.0, 1.0 );
    vec3  nn      = normalize ( n - 7.0*vec3 ( d1, d2, 0.0 ) );
    vec3  ln      = normalize ( lt );
    vec3  hn      = normalize ( ht );
    float diffuse = 0.4 + max ( 0.0, dot ( nn, ln ) );
    float spec    = 0.5*pow ( max ( 0.0, dot ( nn, hn ) ), 30.0 );

   gl_FragColor = vec4 ( f(tex)+f(2.0*tex), 0.0, 0.0, 1.0 )*diffuse + 
                  spec*vec4 ( 1.0, 1.0, 0.0, 1.0 );
}

Очень хороший результат дает применение следующей функции:

clamping F functions

При достаточно большом значении параметра scale получается просто разбиение на плитки, что хорошо видно на следующем рисунке.

Рис 7. "Плитки".

Ниже приводится соответствующий фрагментный шейдер

varying	vec3 lt;
varying	vec3 ht;
varying	vec3 p;

uniform sampler2D cellMap;

void main(void)
{
    vec2  tex   = 0.5*vec2 ( 1.0, 2.0 ) * gl_TexCoord [0].xy;
    vec4  f     = texture2D ( cellMap, tex );
    float scale = 30.0;
    float bias  = 0.04;
    float v     = scale*(f.y - f.x - bias);
	
    vec3  n       = vec3 ( 0.0, 0.0, 1.0 );
    vec3  nn      = n;
    vec3  ln      = normalize ( lt );
    vec3  hn      = normalize ( ht );
    float diffuse = 0.4 + max ( 0.0, dot ( nn, ln ) );
    float spec    = 0.5*pow ( max ( 0.0, dot ( nn, hn ) ), 30.0 );

    gl_FragColor = vec4 ( vec3 ( clamp ( v, 0.0, 1.0 ) ) * diffuse, 1.0 );	
}

При этом параметр bias позволяет управлять размером зазора между плитками - увеличение этого параметра приводит к увеличению зазора.

На основе этого шейдера легко создать эффект разбитых плиток из какого-то материала.

Рис 8. Разбитые плитки.

Соответствующий фрагментный шейдер приводится ниже.

varying	vec3 lt;
varying	vec3 ht;
varying	vec3 p;

uniform sampler2D cellMap;
uniform sampler2D decalMap;

void main(void)
{
    vec2   tex   = 0.5*vec2 ( 1.0, 2.0 ) * gl_TexCoord [0].xy;
    vec4   f     = texture2D ( cellMap,  tex );
    vec3   ct    = texture2D ( decalMap, tex ).xyz;
    float  scale = 30.0;
    float  bias  = 0.04;
    float  v     = scale*(f.y - f.x - bias);
	
    vec3   n       = vec3 ( 0.0, 0.0, 1.0 );
    vec3   nn      = n;
    vec3   ln      = normalize ( lt );
    vec3   hn      = normalize ( ht );
    float  diffuse = 0.4 + max ( 0.0, dot ( nn, ln ) );
    float  spec    = 0.5*pow ( max ( 0.0, dot ( nn, hn ) ), 30.0 );
    vec3   clr     = vec3 ( clamp ( v, 0.0, 1.0 ) );

    gl_FragColor = vec4 ( ct*clr*diffuse, 1.0 );	
}

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

Fractalization of Fs

Следующее изображение получено именно таким путем из функции F1.

fractal F1

Рис 8. Фрактальная версия функции F1.

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

Ранее использовалась следующая метрика:

L2 metric

Ниже приводятся еще два варианта вычисления расстояния между точками.

other metrics

Ниже приводятся графики функции F1 для этих двух метрик.

F1 F1

Рис 9. Графики F1 для новых метрик.

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

Пусть у нас есть набор точек на плоскости P1,P2,...,Pn. Тогда для каждой такой точки Pi нарисуем квадрат с центром в этой точке. Размер квадрата выбирается таким образом, чтобы все точки, для которых Pi будет ближайшей, гарантированного накрывались этим квадратом.

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

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

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

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

По этой ссылке можно скачать весь исходный код к этой статье. Также доступны для скачивания откомпилированные версии для M$ Windows, Linux и Mac OS X.

Используются технологии uCoz