Игровой движок Torque3D от GarageGames. Исправление нормалей на кромках террейна. Склеивание террейнов без швов — моё изменение исходного кода (C++, WorldEditor)

в 11:42, , рубрики: c++, игровые движки, метки: ,

Здравствуйте!

Статья предназначена для тех, кто ведет разработку на Torque3D и умеет компилировать движок в VisualStudio, а не только для тех, кто пользуется WorldEditor и пишет/дописывает torqueScript *.cs скрипты.

В Torque3D 3.5 есть небольшой баг с нормалями на кромках террейна (Terrain). Именно он мешает создавать множество террейнов «без швов» (и не только он). Если запустить какую нибудь демку, например, Empty, открыть в ней WorldEditor, создать 2 террейна, подогнать их друг к другу и попробовать «нарисовать кисточкой высоту» в области «склейки» этих террейнов, то мы увидим неприятный баг. Похожий баг можно увидеть, если мы создадим всего лишь 1 террейн и попробуем отредактировать высоту в областях кромок этого террейна.

Что есть террейн?

Террейн — это регулярная сетка ячеек с высотами ( имеющая размеры 256, 512, 1024 — кратная степени двойки), которая легко преобразуется в поверхность (mesh).
У меша есть не только полигоны и вершины, но также и нормали к вершинам, которые как раз дают нужную освещаемость конкретной вершины.

Дело в том, что в движке Torque3D нормаль в точке x,y рассчитывается исходя из разниц высот:
normal( height(x+1, y) — height (x — 1, y), height(x, y+1) — height(x, y-1), 'какая то постоянная высота' ),
где float height( int x, int y ) — функция взятия высоты по целочисленным координатам регулярной сетки ячеек в террейне.

Да, в Torque3D координата по Z — это высота.

Возникает вопрос: а если x+1 или x-1 или y+1 или y-1 выходят за границу террейна?
Тогда берется точка на противоположенной стороне террейна и возникает артефакт в виде необоснованной повышенной/пониженной освещенности такой «пограничной» вершины. Т.е. если, например, x=0, то x-1 = -1, который превращается в size — 1 (размер террейна минус единичка, например 256 — 1 = 255).

Почему так превращается? Потому что вот функция, возвращающая высоту в точке x, y:

inline U16 TerrainFile::getHeight( U32 x, U32 y ) const
{
x %= mSize;
y %= mSize;
return mHeightMap[ x + ( y * mSize ) ];
}

где mSize — это размер террейна, например 256, а оператор % берет остаток от деления. Тип U32 — это беззнаковый тип, а вызывается эта функция из функции TerrainBlock::getSmoothNormal(...) вот здесь:

F32 h1 = fixedToFloat( mFile->getHeight( x+1, y ) );
F32 h2 = fixedToFloat( mFile->getHeight( x, y+1 ) );
F32 h3 = fixedToFloat( mFile->getHeight( x-1, y ) );
F32 h4 = fixedToFloat( mFile->getHeight( x, y-1 ) );

а x и y они типа S32, что означает знаковый тип. Если приходит -1 на вход этой функции, то значение автоматически преобразуется к беззнаковому, причем к предельному (максимальному) для 32 битных чисел. Это будет 2^32 — 1 = 4 294 967 295 и остаток от деления на 256 будет 255.
4 294 967 295 % 256 = 255.

Я подумал, что нам такого не надо и решил немного подкорректировать исходный код Torque, попутно запилив некий функционал.

Функционал заключается в следующем:
если x-1 < 0 или x+1 > mSize — 1 или y-1<0 или y+1> mSize — 1, то значение высоты в данной точке брать не с текущего террейна, а с тех террейнов, которые мы указали соответствующими соседями.

Для этого поменялся инспектор в редакторе мира для террейна, теперь там есть вкладка «Attached terrains» и поля «ForwardId», «BackwardId», «LeftId» и «RightId», куда пользователь ручками вбивает id'шники соседних террейнов (которые он сочтет нужными, хаха).

Для того, чтобы поменять инспектор, был вставлен код в функцию void TerrainBlock::initPersistFields():

addGroup( "Media" );

addProtectedField( "terrainFile", TypeStringFilename, Offset( mTerrFileName, TerrainBlock ), &TerrainBlock::_setTerrainFile, &defaultProtectedGetFn,"The source terrain data file." );

endGroup( "Media" );

// Вкладка в инспекторе WorldEditor'а для "соседей" террейна
addGroup( "Attached terrains" );
addField( "ForwardId", TypeS32, Offset( m_Forward, TerrainBlock ), "Id of forward attached terrain" );
addField( "BackwardId", TypeS32, Offset( m_Backward, TerrainBlock ), "Id of backward attached terrain" );
addField( "LeftId", TypeS32, Offset( m_Left, TerrainBlock ), "Id of left attached terrain" );
addField( "RightId", TypeS32, Offset( m_Right, TerrainBlock ), "Id of right attached terrain" );
endGroup( "Attached terrains" );
// end of Вкладка

addGroup( "Misc" );
...

В прототип класса class TerrainBlock : public SceneObject были вставлены поля:
S32 m_Forward;
S32 m_Backward;
S32 m_Left;
S32 m_Right;

То есть у меня выглядит вот так:
...
///
FileName mTerrFileName;

/// Attached terrains Соседи террейна
S32 m_Forward;
S32 m_Backward;
S32 m_Left;
S32 m_Right;

/// The maximum detail distance found in the material list.
F32 mMaxDet

Для того, чтобы эти поля сериализовались, надо вставить код в функции TerrainBlock::packUpdate и TerrainBlock::unpackUpdate.

В TerrainBlock::packUpdate вставляется:

// для соседей террейна
if( stream->writeFlag( mask & NextFreeMask ) )
{
stream->write( m_Forward );
stream->write( m_Backward );
stream->write( m_Left );
stream->write( m_Right );
}
//
После строчек:
if ( stream->writeFlag( mask & FileMask ) )
{
stream->write( mTerrFileName );
stream->write( mCRC );
}

В TerrainBlock::unpackUpdate нужно вставить:

// Соседи террейна
if ( stream->readFlag() )
{
stream->read( &m_Forward );
stream->read( &m_Backward );
stream->read( &m_Left );
stream->read( &m_Right );
}
// end of Соседи террейна

После строчек:

if ( stream->readFlag() ) // FileMask
{
FileName terrFile;
stream->read( &terrFile );
stream->read( &mCRC );

if ( isProperlyAdded() )
setFile( terrFile );
else
mTerrFileName = terrFile;
}

Внимание! Код в этих двух функциях должен быть в определенном порядке, а не абы где. Именно там, где я указал, идет работа со stream'ом (наподобии STLвского стрима).

Теперь дошла очередь и до самой функции TerrainBlock::getSmoothNormal(...).

Изменения в terrData.cpp.

Сначала перед самой функцией TerrainBlock::getSmoothNormal(...) надо объявить:

namespace Sim
{
// Defined in simManager.cpp
extern SimIdDictionary *gIdDictionary;
}

Это объявит неймспейс локально прямо в этом файле реализации и продекларирует, что есть такая переменная SimIdDictionary *gIdDictionary.
Прошу заметить, это не создание новой глобальной переменной Sim::gIdDictionary, а лишь указание компилятору (впоследствии линковщику), что такая переменная уже есть в какой то другой единице трансляции — в другом *.cpp файле (а следовательно — в другом *.obj файле, после того как компилятор скушает *.cpp)

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

В функции TerrainBlock::getSmoothNormal(...) после строчек

const TerrainSquare *sq = mFile->findSquare( 0, x, y );
if ( skipEmpty && sq->flags & TerrainSquare::Empty )
return false;

Вставляю следующий код:

Resource File1, File2, File3, File4;
File1 = File2 = File3 = File4 = mFile;

S32 x1 = x + 1;
S32 x2 = x — 1;
S32 y1 = y + 1;
S32 y2 = y — 1;

if( x1 > mFile->mSize — 1 )
{
x1 = mFile->mSize — 1;
TerrainBlock *rBlk = dynamic_cast<TerrainBlock*>( Sim::gIdDictionary->find( m_Right ) );
if( rBlk )
{
x1 = 1;
File1 = rBlk->mFile;
}
}

if( y1 > mFile->mSize — 1 )
{
y1 = mFile->mSize — 1;
TerrainBlock *fBlk = dynamic_cast<TerrainBlock*>( Sim::gIdDictionary->find( m_Forward ) );
if( fBlk )
{
y1 = 1;
File2 = fBlk->mFile;
}
}

if( x2 < 0 )
{
x2 = 0;
TerrainBlock *lBlk = dynamic_cast<TerrainBlock*>( Sim::gIdDictionary->find( m_Left ) );
if( lBlk )
{
x2 = lBlk->mFile->mSize — 2;
File3 = lBlk->mFile;
}
}

if( y2 < 0 )
{
y2 = 0;
TerrainBlock *bBlk = dynamic_cast<TerrainBlock*>( Sim::gIdDictionary->find( m_Backward ) );
if( bBlk )
{
y2 = bBlk->mFile->mSize - 2;
File4 = bBlk->mFile;
}
}

А строчки:

F32 h1 = fixedToFloat( mFile->getHeight( x+1, y ) );
F32 h2 = fixedToFloat( mFile->getHeight( x, y+1 ) );
F32 h3 = fixedToFloat( mFile->getHeight( x-1, y ) );
F32 h4 = fixedToFloat( mFile->getHeight( x, y-1 ) );

Заменить строчками:

F32 h1 = fixedToFloat( File1->getHeight( x1, y ) );
F32 h2 = fixedToFloat( File2->getHeight( x, y1 ) );
F32 h3 = fixedToFloat( File3->getHeight( x2, y ) );
F32 h4 = fixedToFloat( File4->getHeight( x, y2 ) );

Вроде бы все! Перекомпилировать — и ПРОФИТ! Ура!

Кстати, можно оптимизировать по скорости, то есть указатель на соседний террейн не искать в функции TerrainBlock::getSmoothNormal(...), как здесь: например:

TerrainBlock *rBlk = dynamic_cast<TerrainBlock*>( Sim::gIdDictionary->find( m_Right ) );

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

Тогда нужно создать 4 соответствующих указателя как поля класса и, скорее всего, инициализировать их там, где происходит «сериализация» (скорее всего ф-ции TerrainBlock::packUpdate или TerrainBlock::unpackUpdate).

Если бы была возможность вставить сюда измененные файлы исходников — с удовольствием вставил бы!

ТоркEnginesourceterrainterrData.cpp
и
ТоркEnginesourceterrainterrData.h

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

Пока фиксит только нормали. Для сглаживания текстур — отдельная история.

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

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

Автор: kovalexius

Источник

Поделиться

* - обязательные к заполнению поля