Иван Дышленко дата публикации 27-12-2001 13:30 Экспорт текстурированных 3D персонажей
После написания первой части статьи прошло уже много времени и я получил много отзывов от читателей. Разумеется, все отзывы были положительными.J Кроме того я получил немало писем с пожеланиями, львиную долю из которых, составляли пожелания добавления возможности экспорта текстур (координат текстур) из 3D Studio MAX. К слову сказать, еще при написании первой версии утилиты MEGA, я думал об этом дополнении, но сначала должен был убедиться, что это кому-нибудь нужно. И теперь поскольку мои желания совпали с большинством ознакомившихся с утилитой MEGA, я рад представить Вам новую версию - MEGA 1.1.
Оглавление
Как известно любому программисту, пишущему под OpenGL, данная библиотека разрешает использование, в качестве текстур, двумерных изображений. При этом размерность текстуры и по ширине, и по высоте, должна быть кратной степени двойки (2, 4, 8 … и т.д.). Кроме того для грамотного размещения текстуры на объекте OpenGL должна знать особые координаты. Эти координаты получили название координат текстуры, но в данной статье я буду называть их вершинами текстуры. Я это делаю, чтобы не путать их с вершинами объекта, а также с гранями текстуры. Что такое грани текстуры я объясню позже.
Каждая грань объекта, которая должна быть текстурирована, обязательно должна иметь указанные вершины текстуры. Вершина текстуры, для двумерной текстуры, представляет собой две координаты X и Y на двумерном изображении. Таких вершин текстуры, для треугольной грани, должно быть три (по одной на каждую вершину грани).
Как правило, координаты вершин указываются в диапазоне от 0.0 до 1.0. Например: (0.5, 0.8). При этом OpenGL полагает, что какого бы ни была размера текстура в пикселях, высота и ширина текстуры равны 1.0. Реальная координата пикселя вычисляется путем интерполяции. Я не знаю точно, но подозреваю, что координаты вершин текстур, можно задавать и в целочисленном виде (как правило, OpenGL содержит набор идентичные функций для разных типов параметров). Тогда возникает резонный вопрос: а зачем возиться с плавающими числами, если целые числа обрабатываются всегда быстрее? На этот вопрос можно привести такой пример:
Предположим, что у Вас имеется высококачественная текстура чего-либо размером 1024х1024. Но, в целях уменьшения расхода памяти, Вы решили сжать ее до размера 256х256 пикселей. Затем Вы создали трехмерный объект, наложили на него текстуру (задавая целочисленные параметры) и увидели, что такое качество слишком низко для реализации Вашей задачи. И вот если Вы решите вернуться к прежнему размеру, а координаты вершин текстуры задавали в целочисленном виде, то и эти данные придется переделывать. Возможно это еще не единственная причина, по которой следует задавать координаты вершин текстур в виде - от 0.0 до 1.0.
В начало страницы
Работа с утилитой MEGA 1.1 |
Новые возможности утилиты практически не изменили принципов работы с ней. Тем , кто не знаком с принципами экспорта 3D персонажей с помощью MEGA, я настоятельно рекомендую прочесть первую часть статьи (ВСТАВИТЬ ССЫЛКУ!!!).
В свитке MEGA, который раскрывается после запуска утилиты, в разделе "Options" добавился один пункт "Export texture vertices". Для того, чтобы экспортитровать координаты вершин текстуры, достаточно отметить этот пункт. В этом случае, при экспорте, в конец файла будет вставлен блок координат текстуры. Независимо от количества кадров анимации, данный блок будет вставлен один раз. Однако, если Вы выделили сразу несколько объектов, то, возможно, таких блоков будет столько сколько объектов выделено. Заранее приношу свои извинения за то, что я это не проверял, но у меня нет времени, на то, чтобы тщательно провести все тесты. Таким образом, я могу гарантировать корректный экспорт координат вершин и граней текстуры, только, если Вы выделили один объект. Остальное проверьте сами.
Примечание:
экспорт параметров наложения текстуры будет производиться только для объектов типа "Сетка" (Editable Mesh), и только, если объект выделен. Для всех остальных объектов экспорт параметров наложения текстуры произведен не будет. Кроме того, MEGA 1.1 полагает, что для объекта установлена только одна текстурная карта (Diffuse(диффузное отражение)/Bitmap(Растровое изображение)). Практически таких карт может быть несколько (до 256), но после экспорта Вы вряд ли разберетесь какой карте какая вершина принадлежит. Поэтому следует по-возможности использовать одну текстурную карту. Если же Вам необходимо использовать несколько карт для одного объекта, то я мог предложить такой выход из ситуации:
Предположим, что у Вас имеется человекоподобный персонаж, и Вы хотите, чтобы для его тела существовал один растр, а для головы другой. Тогда Вам следует создать два файла .max, в одном из них Вы разместите на объекте текстуру для тела, а в другом текстуру для головы. После этого, осуществите экспорт с помощью MEGA 1.1 этих файлов по очереди. Тогда, на выходе, один файл у Вас будет содержать координаты вершин и граней текстуры для тела, а другой - координаты вершин и граней текстуры для головы. В итоге, Вы сможете в своем приложении накладывать на один объект две независимые текстуры.
В начало страницы
В целом, формат выходного файла MEGA изменился несущественно. В конец файла добавляется блок вершин и граней текстуры. Выглядит этот блок следующим образом:
Начинается блок строкой:
New Texture:
numtverts numtvfaces
//Здесь располагаются два целых числа,
//указывающих количество вершин и граней текстуры соответственно
Texture vertices:
// Здесь располагается блок вершин текстуры.
//Каждая строка соджержит три дробных числа соответствующих координатам UVW.
end texture vertices // Конец блока вершин текстуры
Texture faces:
// Здесь располагается блок граней текстуры. Каждая строка содержит три целых числа,
// являющихся индексами (ссылками) на вершины текстуры.
end texture faces // Конец блока граней текстуры.
end of texture //Конец блока текстуры | |
В начало страницы
До начала написания этой статьи, я первый раз попытался надеть текстуру на такой сложный объект, как человеческая фигура и наступил на целый ряд граблей. Поэтому здесь я хочу дать рекомендации по способу наложения текстур, но сначала я расскажу откуда берутся значения поступающие в файл GMS.
Когда Вы накладываете текстуру в 3D MAX'e, MAX старается идти по пути минимизации размера данных. А именно… Предположим, что вы накладываете текстуру на треугольную грань.
Тогда, следует задать три текстурные вершины, по одной на каждую вершину грани. Однако, ситуация меняется, если Вы накладываете текстуру на несколько связанных граней одновременно.
Взгляните на второй рисунок. Здесь уже не нужно задавать по три вершины текстуры для каждой треугольной грани. Ведь тогда некоторые вершины текстуры будут иметь одинаковые координаты. А зачем их дублировать? Вот и MAX полагает, что незачем. При накладывании текстуры, он отмечает только те координты, в которых расположены вершины граней, и каждую из них помещает в свой глобальный массив вершин текстуры. В идеале, глобальный массив вершин текстуры содержит столько же элементов, сколько вершин имеет объект. Однако понять, какие из элементов этого массива, к каким граням объекта относятся невозможно без специальных указаний. Эти указания есть, и я их называю глобальным массивом граней текстуры. Количество элементов этого массива всегда соответствует количеству граней объекта, и каждый элемент представляет собой набор из трех целочисленных значений, каждое из которых, является индексом в глобальном массиве вершин текстуры. Зная номер грани, мы по этому номеру можем обратиться к глобальному массиву граней текстуры, получить три целых индекса, обратиться по этим индексам к глобальному массиву вершин текстуры и получить реальные координаты вершин текстуры. Поэтому при экспорте координат текстуры с помощью MEGA 1.1 на выходе мы получаем массив вершин текстуры и массив граней текстуры.
Далее, если Вы внимательно посмотрите на выходные данные GMS, то увидите, что в блоке вершин тектуры каждая вершина представлена тремя координатами, вместо двух, используемых при накладывании текстуры в OpenGL. Дело в том, что MAX хранит три координаты текстуры UVW, которые, как сказано в электронной справке по 3D Studio MAX 3.0 являются аналогами координат XYZ. U - соответствует координате X, V - координате Y, W - координате Z. Координата Z в MAX'e преимущественно используется для трехмерных процедурных карт, таких как Stucco(штукатурка). В нашем случае ей можно воспользоваться, для того чтобы отобразить текстуру зеркально. Однако мы можем обойтись и без этого. В примере, приложенном к данной статье, третью координату W я просто оставляю без внимания.
Чем лучше воспользоваться?
В первый раз, при размещении текстуры на человекоподобном персонаже, я воспользовался стандартным модификатором "UVW". Сразу скажу, что занятие оказалось не для слабонервных. Мне удалось-таки наложить текстуру, но я потратил очень много времени и эмоций. Кроме того, текстура местами лежала весьма криво, и в целом все выглядело достаточно убого. Но и это еще не все. После экспорта данных текстуры, наложенной с помощью UVW модификатора, мне просто необходимо было бы воспользоваться третьей координатой W при накладывании текстуры в OpenGL. Иначе текстура легла бы не ровно. Кроме того, при работе с текстурой в MAX'e мне пришлось создать около тридцати!!! одинаковых текстурных карт для различных участков тела одного объекта. Все это вышло слишком громоздко и неуклюже.
На самом деле, мне следовало сперва заглянуть в электронную справку по 3D Studio MAX'у и прочитать раздел "Game Development Features". Там рассказывается о модификаторе Unwrap UVW (Развернуть по UVW). Этот модификатор предназначен именно для накладывания текстур на модели невысокого разрешения. При этом можно создать только одну текстурную карту. Подробнее об этом модификаторе Вы сможете прочитать в электронной справке по 3D Studio MAX'у, для этого достаточно владеть английским на уровне средней школы. Скажу, что после того, как я ознакомился с данным модификатором, накладываие текстуры на объект, который используется в примере, заняло у меня не более трех часов.
В начало страницы
Пример использования данных текстуры в Delphi и OpenGL |
За основу кода я взял последний пример из первой части статьи. В первую очередь мной был добавлен модуль TextureGL ответственный за загрузку и инициализацию текстуры. В модуле реализован объект TTextureGL:
Type
TTextureGL = class
Width,
Height : Integer; // Ширина и высота текстуры
pBits : pByteArray; // Собственно данные
Destructor Destroy; override;
procedure LoadFromFile( const AFileName : String);
procedure Enable; // включает режим отображения текстуры
procedure Disable; // выключает режим отображения текстуры
end;
Я не стал изобретать колесо, поэтому загрузка текстуры из растра выглядит примитивно и неэффективно, но у меня не было цели создавать высокоскоростное приложение.
procedure TTextureGl.LoadFromFile( const AFileName : String);
var B : TBitmap;
i,j : Integer;
begin
B := TBitmap.Create;
B.LoadFromFile(AFileName);
Width := B.Width;
Height := B.Height;
GetMem(pBits,Width*Height*3); // выделить память для данных
for j := 0 to Height - 1 do begin
for i := 0 to Width - 1 do begin
pBits[(j*Width + i)*3] := GetRValue(B.Canvas.Pixels[i,j]); // Перенести данные о цветах пикселей
pBits[(j*Width + i)*3+1] := GetGValue(B.Canvas.Pixels[i,j]); // из объекта TBitmap
pBits[(j*Width + i)*3+2] := GetBValue(B.Canvas.Pixels[i,j]); // в объект TTextureGL
end;
end;
B.Free; // Освободить объект TBitmap
end;
Следующая процедура устанавливает параметры текстуры и включает ее отображение.
procedure TTextureGL.Enable;
begin
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA,Width,Height,0,GL_RGB,GL_UNSIGNED_BYTE,pBits);
glTexEnvi(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,GL_DECAL);
glEnable(GL_TEXTURE_2D);
end;
Если Вам непонятен этот участок кода обратитесь к документации по OpenGL. Список рекомендуемой литературы находится в конце статьи.
Процедура Disable состоит из одной строки и отключает режим вывода текущей текстуры на экран.
Последняя процедура-деструктор освобождает память, выделенную для хранения текстуры, и уничтожает объект TTextureGL:
Destructor TTextureGL.Destroy;
begin
if Assigned(pBits) then FreeMem(pBits);
Inherited Destroy;
end;
Теперь перейдем к рассмотрению изменений в модуле Mesh.pas. Как Вы, возможно, помните, у меня создается для одной модели один объект TGLMultyMesh, который содержит список объектов TGLMesh. В каждом объекте TGLMesh хранится один сетчатый объект соответствующий одному кадру анимации. При отрисовке кадра объект TGLMultyMesh обращается к объекту TGLMesh по номеру в списке, и вызывает метод Draw объекта TGLMesh. Поскольку для всех сеток анимации координаты текстуры будут одинаковы, я разместил данные текстуры в объекте TGLMultyMesh, а чтобы, во время отриосвки кадра, объект TGLMesh мог обратиться к ним, добавил в список переменных объекта TGLMesh ссылку на родительский объект TGLMultyMesh.
TGLMultyMesh = class;
TGLMesh = class
Vertices : PGLVertexArray; // Массив вершин
Faces : PGLFacesArray; // Массив граней
FasetNormals : PGLVertexArray; // Массив фасетных нормалей
SmoothNormals : PGLVertexArray; // Массив сглаживающих нормалей
VertexCount : Integer; // Число вершин
FacesCount : Integer; // Число граней
fExtent : GLFloat; // Масштабный коэффициент
Extent : GLBoolean; // Флаг масштабирования
Parent : TGLMultyMesh; // Ссылка на родительский объект
…………………………………..
Сам метод TGLMesh.Draw дополнился, при вызове, новым параметром Textured, который указывает - предоставлена текстура для данного объекта или нет.
Загрузка файла GMS выполняется объектом TGLMultyMesh, который претерпел следующие изменения:
В описание объекта добавлены переменные:
TexVertices : PGLVertexArray; // Массив вершин текстуры
TexFaces : PGLFacesArray; // Массив граней текстуры
TexVCount, TexFCount : Integer; // Количество вершин и граней текстуры
TexturePresent : Boolean; // Указывает - загружены данные текстуры или нет
Метод LoadFromFile дополнился локальной процедурой ReadTextureBlock:
Procedure ReadTextureBlock;
var
i : Integer;
Vertex : TGLVertex;
Face : TGLFace;
Begin
Readln(f,S); // пропускаем строку "numtverts numtvfaces"
Readln(f,TexVCount,TexFCount); // Читаем данные о количестве вершин и граней текстуры
if Assigned(TexVertices) then FreeMem(TexVertices); // Очищаем память если она ранее была выделена
if Assigned(TexFaces) then FreeMem(TexFaces);
GetMem(TexVertices,TexVCount*SizeOf(TGLVertex)); // Выделяем память для хранения данных текстуры
GetMem(TexFaces,TexFCount*SizeOf(TGLFace));
Readln(f,S);
// На всякий случай, мало ли что
if S <> 'Texture vertices:' then begin
ShowMessage('Texture not present!');
TexturePresent := False;
Exit;
end;
// Считываем вершины текстуры
for i := 0 to TexVCount - 1 do begin
Readln(f,Vertex.x,Vertex.y,Vertex.z);
TexVertices[i] := Vertex;
end;
Readln(f,S);// Пропускаем строку "end texture vertices"
Readln(f,S);// Пропускаем строку "Texture faces:"
// считываем грани текстуры
for i := 0 to TexFCount - 1 do begin
Readln(f,Face[0],Face[1],Face[2]);
Face[0] := Face[0] - 1;
Face[1] := Face[1] - 1;
Face[2] := Face[2] - 1;
TexFaces[i] := Face;
end;
TexturePresent := True; // Устанавливаем флаг, что текстура имеется
end;
В целом она аналогична процедуре загрузки сетки. Обратите внимание, что в той части кода, где считываются грани текстуры, из индексов граней вычитается единица. Это делается потому, что нумерация граней начинается в MAX'e с единицы, а в моем примере с нуля.
Сам цикл считывания данных объекта теперь выглядит так:
begin
Meshes := TList.Create;
AssignFile(f,FileName);
Reset(f);
While not Eof(f) do begin
Readln(f,S);
if S = 'New object' then ReadNextMesh(Self);
if S = 'New Texture:' then ReadTextureBlock; // Считываем текстуру
end;
CloseFile(f);
end;
В процедуру ReadNextMesh теперь передается параметр - ссылка на родительский объект. В самой процедуре происходит присваивание:
NextMesh.Parent := AParent;
В методе Draw изменилась одна строка:
TGLMesh(Meshes.Items[CurrentFrame]).Draw(fSmooth,TexturePresent);
Теперь рассмотрим изменения в объекте TGLMesh. Изменения коснулись только метода Draw.
procedure TGLMesh.Draw(Smooth, Textured: Boolean);
var i : Integer;
Face,TexFace : TGLFace;
TexVertex : TGLVertex;
begin
for i := 0 to FacesCount - 1 do begin
glBegin(GL_TRIANGLES);
Face := Faces[i];
if Smooth then begin
glNormal3fv(@SmoothNormals[Face[0]]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[0]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[0]]);
glNormal3fv(@SmoothNormals[Face[1]]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[1]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[1]]);
glNormal3fv(@SmoothNormals[Face[2]]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[2]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[2]]);
end else begin
glNormal3fv(@FasetNormals[i]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[0]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[0]]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[1]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[1]]);
if Textured then begin
TexFace := Parent.TexFaces[i];
TexVertex := Parent.TexVertices[TexFace[2]];
glTexCoord2f(TexVertex.x,1-TexVertex.y);
end;
glVertex3fv(@Vertices[Face[2]]);
end;
glEnd;
end;
end;
Здесь все почти по-прежнему кроме участков кода, которые выполняют назначение координат текстур:
if Textured then begin
TexFace := Parent.TexFaces[i]; // Получить грань за номером i
TexVertex := Parent.TexVertices[TexFace[2]]; // Получить одну вершину текстуры для этой грани
glTexCoord2f(TexVertex.x,1-TexVertex.y); // Назначить координаты вершины текстуры
end;
Как видно из этого участка кода, я действительно использую лишь две координаты - X и Y для отображения текстуры. Обратите внимание, что я не использую координату Y в чистом виде, а вычитаю ее из единицы: 1-TexVertex.y. Дело в том, что 3D Studio MAX использует более правильную координатную систему, когда координата с меньшим значением находится ниже координаты с большим значением, относительно растра. Я подозревал, что использеутся такой подход, еще при размещении текстуры на объекте в 3D MAX'e. Поэтому для меня не стало потрясением, когда при первом запуске примера у персонажа штаны оказались на голове, а бронежилет на пятках. Однако, запомните, что, во избежание стрессов, за этим нужно следить всегда.
Теперь мы можем перейти к рассмотрению основного модуля frmMain.pas.
В методе TfrmGL.FormCreate добавились строки:
Texture := TTextureGL.Create;
Texture.LoadFromFile('Gurilla_0.bmp');
Здесь происходит создание и загрузка текстуры.
Еще добавились два обработчика событий нажатия пункта меню, которые включают и выключают отображение текстуры. И все. Скомпилируйте пример и запустите на выполнение. Вы должны увидеть что-то вроде этого:
Если Вы где-нибудь видели этого человека, немедленно сообщите в органы внутренних дел. Его уже давно разыскивают. :)
Примечание:
Если вы нажали пункт меню "текстура" и меню не выпало, переместите курсор чуть пониже меню. Оно должно выпасть
На этом статью можно и заканчивать. Хочу только привести напоследок реальный пример из жизни, который показывает насколько важно задавать координаты текстур в значениях от 0.0 до 0.1. Дело в том, что данную модель я позаимствовал из игры Counter-Strike, естественно вместе с текстурой. Поскольку я не знаю формата mdl, мне пришлось экспортировать ее в файл DXF с помощью одной утилиты. После чего я перенес DXF в 3D MAX и принялся за работу по накладыванию текстуры. Когда я закончил работать с текстурой и начал писать приложение, я вдруг обнаружил, что размер текстуры не кратен степени двойки. Оригинальный размер текстуры - 256х251. Я не знаю, как с этим справлялись разработчики игры Counter-Strike, но у меня OpenGL с такой текстурой работать не захотел. Дополнять недостающие строчки растра и снова проделывать работу по натягиванию текстуры не хотелось. Но этого делать и не пришлось. Я просто растянул текстуру по вертикали при помощи программы PhotoShop 4.0 и текстура нормально легла на объект. Потому что, при задании координат текстуры от 0.0 до 1.0 не имеет значения, каких размеров будет текстура.
На этом прощаюсь.
Иван Дышленко
В начало страницы
Список литературы
- "Эффективная работа с 3D Studio MAX 2", Майкл Тодд Петерсон при участии Ларри Минтона.
- "Анимация персонажей в 3D Studio MAX", Стефани Рис.
- "OpenGL графика в проектах Delphi", Михаил Краснов.
- "Интерактивная компьютерная графика. Вводный курс на базе OpenGL", Эдвард Эйнджел.
- "3D Studio MAX R3 Online Reference"
- "3D Studio MAX R3 MAXScript Reference"
- "3D Studio MAX R3 Online Tutorials"
В начало страницы
ЧАВО
Q. Как сделать так, чтобы скрипт сохранял данные не в текстовом, а в бинарном формате?
A. А кому это нужно? Напишите конвертер по своему усмотрению, зачем загромождать и без того запутанный скрипт?
Q. Могу я использовать допустим его (формат GMS) в коммерческих версиях своего движка(в будущем)???
А. Да ради бога. Только опять же… Лучше преобразовать в бинарный. Он и загружаться быстрее будет и места занимать меньше и не позаимствует никто.
Q. Накладываются на этот формат какие либо ограничения??
А. Можете использовать его в своих целях, как угодно.
Q. У меня вопрос по поводу утилиты Meshes Export for Games and Animation (я ещев ней не разбирался). Можно ли, с помощью нее, экспортировать (может что-то изменив) положения скелетной модели? И еще. А если мне нужно экспортировать не сеточную модель, а контрольные вершины (CV) NURBS объекта? Может что-нибудь посоветуете?
А. Можно экспортировать трансформации положения, поворота и масштаба. Но не в этой версии. Добавлю в следующих. А NURBS и Patch не поддерживаются.
Q. У меня стоит 4-й MAX, и макрос в нем не открывался, пока я не открыл редактор rollout'а и не нажал "сохранить" не внося изменений. Возможно, у кого-то будут аналогичные проблемы и вопросы...
А. Очень может быть. Разработка велась под версию 3.0. Спасибо, Ваше послание наверняка многим поможет.
Q. Возможен ли экспорт других объектов? К примеру - кубы и конусы, не преобразованные в сетку, а также Shapes (полилайны, эллипсы...) с указанием всех параметров, включая положение,повороты и т.п.
А. Экспорт примитивов возможен. Если Вы не станете примитив преобразовывать в редактируемую сетку, то на выходе у Вас будет указание, что это за примитив и набор параметров: высота, ширина, глубина и т.д. Только разбираться, что есть что Вам придется самостоятельно. В файле будут только цифры. Параметры трансформации я добавлю позже. Насчет всевозможных кривых - не знаю. Я не пробовал. Скорее всего, экспорт таких объектов не получится.
Иван Дышленко , 27 декабря 2001 г
Материал предоставлен автором специально для Королевства Delphi
В начало страницы
Скачать проект Texture3D.zip (237K)
[OPENGL]
Обсуждение материала [ 18-10-2010 11:57 ] 13 сообщений |