Бесплатная реклама

Автор: Артем Мерец «Scart»

Опубликовано: 01.03.2008

Изменено: 01.03.2008

Постоянная ссылка

Комментарии [8]

Водная поверхность в трехмерных приложениях (на DirectX и C++)


Реализация водной поверхности в 3D приложениях с освещением, волнами и отражением от объекта на C++ и DirectX.


1. Введение
2. Реализация
3. Применение
4. Исходный код

1. Введение

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

Что же она в себя включает? Давайте разберемся.
1. Отражение – отображение на поверхности воды пространства, находящегося над ней. Этот пункт является, пожалуй, самым главным при изображении более-менее реалистичной воды.
2. Преломление – отображение пространства, находящегося под водной поверхностью.
3. Туманность/плотность воды – изменение цвета преломления в зависимости от глубины (чем глубже по отношению к поверхности воды расположено пространство, тем темнее/мутнее оно выглядит).
4. Освещение.
5. Волны – тоже немаловажный пункт для реализации реалистичной водной поверхности.

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

1. Для начала определимся что из себя представляет вода. Есть несколько случаев:
- квад
- малополигональная модель
- многополигональная модель.

В зависимости от поставленной задачи выбирается один из способов реализации. Самый простой - водная поверхность представляет из себя квадрат (два полигона). Физическое движение отсутствует. Эффект волновой поверхности достигается лишь с помощью специальных карт смещений.
Малополигональная модель – ничем не отличается от предыдущей, за исключением того, что поверхность состоит из нескольких полигонов, совершающих колебания по определенному закону (в простейшем случае по синусоиду). Таким образом, создается видимость «более динамичного» движения волн.
Многополигональная модель – совершенно другой способ. Волны представляют собой массивы динамически изменяющихся примитивов. Самое честное изображение волн, но и самый ресурсоемкий вариант. Стоит использовать только тогда, когда необходим наивысший уровень качества.
Чаще всего используется первый или второй вариант.

2. Отражение. Отражение на поверхности воды есть ничто иное, как текстура. Сложность заключается лишь в том, что она постоянно меняется и одной постоянной текстурой не обойтись (за исключением фейкового отражения – текстура неба; метод нереалистичный и следует его использовать только для ускорения отрисовки, если оное требуется). Давайте разберемся, откуда это изображение брать. Отражение на поверхности воды представляет собой изображение пространства, находящегося над водой, но отраженное по горизонтали.

Чтобы его получить нужно всего лишь "отразить" камеру относительно поверхности воды.

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

4. Освещение. Ничего необычного, простое освещение, такое же, как и у остальных объектов (чтобы вода не была однотонного цвета).

5. Волны. Как мы уже говорили – волны можно изобразить двумя способами (фейковые волны с помощью текстур и корректные волны с помощью геометрии).
Если с геометрией все понятно (позиция каждой вершины меняется в зависимости от физического закона или карты шумов), то фейк делается чуть запутанней, но, при этом, проще.
Фейковые волны можно отобразить с помощью двух вещей: свет и искажение отражения/ преломления. Наиболее качественные результаты достигаются с помощью совместного использования этих двух способов.


С помощью карт нормалей мы освещаем воду так, будто на ней
действительно есть рельефны волн.


С помощью искажения мы "размазываем" отражение/преломление по структуре волн.

Как уже упоминалось, освещать по структуре волн мы будем с помощью карт нормалей.


Карта нормалей

Карта нормалей представляет собой графический файл, в r/g/b каналы которого записаны координаты вектора нормали, а не цвета. Как правило, она выглядит так (карта в которой не трудно разглядеть структуру объекта, освещенную сине-фиолетовым цветом).

Искажать изображение на поверхности воды (будь то преломление или отражение, или их комбинация) мы будем с помощью карты смещений текстурных координат.


Карта смещений текстурных координат (du/dv карта)

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

Итак, давайте из полученных знаний составим небольшой план по рисованию воды в нашем приложении. Нам нужно:
1. квад;
2. две вспомогательные текстуры (normal map и du/dv map);
3. несколько методов для нормального рендеринга отражения;
4. шейдер, который все это дело отрисует.

Ну что же, переходим к реализации.

2. Реализация

#define FVF_XYZ_TEX1 (D3DFVF_XYZ|D3DFVF_TEX1)
struct VERTEX_XYZ_TEX1
{
	VERTEX_XYZ_TEX1()
	{
	};
	VERTEX_XYZ_TEX1( D3DXVECTOR3 _pos, D3DXVECTOR2 _tex )
	{
		pos = _pos;
		tex = _tex;
	};
	D3DXVECTOR3 pos;
	D3DXVECTOR2 tex;
};

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

class CWATER
{
private:
	LPDIRECT3DSURFACE9	psBackBuffer;
	LPDIRECT3DSURFACE9	psTextureReflect;
	D3DXMATRIX		oldMatView;
	LPDIRECT3DVERTEXBUFFER9 vb_Water;
	LPDIRECT3DSURFACE9	pSurfaceZBuffer;
	LPDIRECT3DSURFACE9	pLastSurfaceZBuffer;
	// Разрешение текстуры отражения и дополнительного буфера глубина
	int iTextureResolution;
	float fWidthWater;	// Длина и ширина водной поверхности
	float fDepthWater;	// Длина и ширина водной поверхности
public:
	LPDIRECT3DTEXTURE9 tex_Reflect;	// Текстура отражения
	D3DXVECTOR3 ObjPos;		// Левая ближняя вершина воды
	void PreRenderForReflection(Camera *Cam);
	void PostRenderForReflection(Camera *Cam);
	CWATER();
	~CWATER();
	void Init(float _fWidthWater, float _fDepthWater, float fTexScale);	// Инициализация
	void Draw();
};

Вот основной класс для воды. Здесь мы видим несколько переменных, о которых не было и намека ранее. Давайте разберемся, зачем они?

Рисовать отражение (при желании и преломление) мы будем в текстуру. А значит, нам сначала нужно создать поверхность для этой текстуры. Хранить ее будем в psTextureReflect. Перед рисованием пространства в нее, нужно указать устройству, что рендеринг делается в текстуру, а не в задний буфер. Но для того, чтобы потом корректно рисовать в задний буфер нужно его запомнить, а потом вернуть. Запоминать его будем в psBackBuffer.

Так мы меняем активный Render Target, но для того, чтобы не было проблем при рисовании в текстуру (рисуется как будто буфер глубины отключен или неправильно выставлено отсечение) нужно сделать еще кое что. Дело в том, что размер заднего буфера равен определенному значению (как правило, 1024x768), а когда рисуем в текстуру, то, скорее всего, она имеет другое разрешение, а значит, данный буфер глубины для нее не подходит. Можно использовать его, только если размер буфера больше или равен размеру текстуры, в которую рисуем.

Получается, что нам придется создать еще две поверхности (для запоминания прошлого и хранения текущего буфера). Это pSurfaceZBuffer и pLastSurfaceZBuffer.
iTextureResolution – разрешение текстуры для отражения (чем больше, тем более качественное отражение, но и более ресурсоемкий метод).

CWATER::CWATER()
{
	ObjPos = D3DXVECTOR3(0.0f,5.0f,0.0f);
	vb_Water	= NULL;
	tex_Reflect	= NULL;
	iTextureResolution=1024;
	fWidthWater=10.0f;
	fDepthWater=10.0f;
}

В конструкторе мы устанавливаем значения по умолчанию и обнуляем указатели на поверхностей.

CWATER::~CWATER()
{
	CLEAR(vb_Water);
	CLEAR(psTextureReflect);
	CLEAR(psBackBuffer);
	CLEAR(pLastSurfaceZBuffer);
	CLEAR(pSurfaceZBuffer);
}

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

void CWATER::Init(float _fWidthWater=10.0f, float _fDepthWater=10.0f, float fTexScale=1.0f)
{
	fWidthWater=_fWidthWater;
	fDepthWater=_fDepthWater;

	D3DXCreateTexture(D3DDevice, iTextureResolution, iTextureResolution, 1, 
			D3DUSAGE_RENDERTARGET, D3DFMT_A8R8G8B8, D3DPOOL_DEFAULT, &tex_Reflect);

	D3DDevice->CreateVertexBuffer(6*sizeof(VERTEX_XYZ_TEX1), D3DUSAGE_WRITEONLY, FVF_XYZ_TEX1, 
			D3DPOOL_MANAGED, &vb_Water, NULL);

	VERTEX_XYZ_TEX1 p[6];
	int k = 0;
	p[k].pos = D3DXVECTOR3( fWidthWater, 0, fDepthWater );
	p[k].tex = D3DXVECTOR2(fTexScale,fTexScale);
	++k;
	p[k].pos = D3DXVECTOR3( fWidthWater, 0, 0.0f );
	p[k].tex = D3DXVECTOR2(fTexScale,0.0f);
	++k;
	p[k].pos = D3DXVECTOR3( 0.0f, 0.0f, 0.0f );
	p[k].tex = D3DXVECTOR2(0.0f,0.0f);
	++k;
	p[k].pos = D3DXVECTOR3( 0.0f, 0, 0.0f );
	p[k].tex = D3DXVECTOR2(0.0f,0.0f);
	++k;
	p[k].pos = D3DXVECTOR3( 0.0f, 0, fDepthWater );
	p[k].tex = D3DXVECTOR2(0.0f,fTexScale);
	++k;
	p[k].pos = D3DXVECTOR3( fWidthWater, 0, fDepthWater );
	p[k].tex = D3DXVECTOR2(fTexScale,fTexScale);
	++k;
	void * pBuf;
	vb_Water->Lock( 0, 6 * sizeof(VERTEX_XYZ_TEX1), &pBuf, 0 );
	memcpy( pBuf, p, 6 * sizeof(VERTEX_XYZ_TEX1));
	vb_Water->Unlock();

	D3DDevice->CreateDepthStencilSurface(iTextureResolution, iTextureResolution, 
			D3DFMT_D24S8, D3DMULTISAMPLE_NONE, 0, TRUE, &pSurfaceZBuffer, NULL);
}

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

void CWATER::Draw()
{
	D3DDevice -> SetFVF( FVF_XYZ_TEX1 );
	D3DDevice -> SetStreamSource( 0, vb_Water, 0, sizeof(VERTEX_XYZ_TEX1) );
    	D3DDevice -> DrawPrimitive( D3DPT_TRIANGLELIST, 0, 2 );
}

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

void CWATER::PreRenderForReflection(Camera *Cam)
{
	D3DXVECTOR3 CamPos = Cam->GetCamPos();
	D3DXVECTOR3 ViewPos = Cam->GetViewPos();
	Cam->GetViewMat(&oldMatView);
	D3DXVECTOR3 h = CamPos;
	float tmp = CamPos.y-ObjPos.y;
	h.y = CamPos.y - 2*tmp;
	D3DXVECTOR3 hv = ViewPos;
	float tmpv = ViewPos.y-ObjPos.y;
	hv.y = ViewPos.y - 2*tmpv;
	D3DXMATRIX MatrixView;
	D3DXMatrixLookAtLH(&MatrixView,&h,&hv,&D3DXVECTOR3(0.0f, 1.0f, 0.0f));
	D3DDevice -> SetTransform(D3DTS_VIEW, &MatrixView);
	D3DDevice->GetRenderTarget( 0, &psBackBuffer );
	tex_Reflect->GetSurfaceLevel(0, &psTextureReflect);
	D3DDevice->SetRenderTarget(0, psTextureReflect);
	D3DDevice->GetDepthStencilSurface(&pLastSurfaceZBuffer);
	D3DDevice->SetDepthStencilSurface(pSurfaceZBuffer);
	D3DDevice->Clear( 0L, NULL, D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER, 
				D3DCOLOR_ARGB(0,180,180,245), 1.0f, 0L );
}

А вот эта функция делает около половины всей работы по рендеру отражению. Здесь мы берем позицию камеры и позицию, на которую она смотрит (позицию взгляда, не направление), запоминаем текущую матрицу вида. Далее небольшие махинации по перемещению камеры в нужное место (чтобы отражение было правильным, мы переносим камеру относительно поверхности воды, делать это можно и другим способом, например через функции reflect’а от плоскости воды (из библиотеки D3DX) и пр., но смысл остается один). Формируем и ставим новую матрицу вида. Далее сохраняем и выставляем новые значения рендеринга для устройства (поверхности). И, конечно, очищаем текстуру.

void CWATER::PostRenderForReflection(Camera *Cam)
{
	Cam->SetViewMat(&oldMatView);
	D3DDevice->SetRenderTarget(0, psBackBuffer);
	D3DDevice->SetDepthStencilSurface(pLastSurfaceZBuffer);
}

После того, как отражение отрисовано в текстуру надо все вернуть на место, так как было до трансформации камеры.

А теперь рассмотрим вторую половину всего рендеринга водной поверхности – шейдер рисования.

static float4x4  Mr=
{	0.5,	0,	0,	0, 
    	0,	0.5,	0,	0, 
    	0,	0,	0.5,	0, 
    	0.5,	0.5,	0.5,	1
};

Это то, благодаря чему мы в состоянии корректно отрисовать именно отражение – "матрица отражения". Она такая, какая она есть и нечего здесь менять не нужно. Постоянная величина, так сказать.

float		f_height_wave;	//высота волны
float4x4	WVP;		//world * view * projection matrix
uniform float	time;		//delta time - время прошедшее за кадр
texture		tex_tex;	//текстура отражения
texture		tex_bumpMap;	//текстура с бампом
texture		tex_bumpdudv1;	//текстура с коэффициентами смещения координат
float3		vec_light_dir;	//вектор направления света
float4		EyePosition;	//позиция камеры
float4		LightColor;	//цвет света
float4		MatColor;	//цвет воды
float4x4	World;		//матрица мира
float		Ambient;	//свет "фона"

Тут и так все понятно, кроме f_height_wave. Это коэффициент смещения текстурных координат.


"Высота" волны = 12


"Высота" волны = 2

sampler tex = sampler_state
{Texture = (tex_tex); ADDRESSU=CLAMP; ADDRESSV=CLAMP;};

sampler bumpMap = sampler_state
{Texture = (tex_bumpMap);MIPFILTER = LINEAR;MAGFILTER = LINEAR;MINFILTER = LINEAR;};

sampler bumpdudv1 = sampler_state
{Texture = (tex_bumpdudv1);MIPFILTER = LINEAR;MAGFILTER = LINEAR;MINFILTER = LINEAR;};

struct VS_INPUT
{
	float4 pos: POSITION;
	float2 texc:TEXCOORD0;
};

struct VS_OUTPUT
{
	float4 pos: POSITION;		//позиция
	float2 tex2:TEXCOORD0;		//текстурные координаты для bump карты
	float2 tex3:TEXCOORD1;		//текстурные координаты для du/dv карты
	float4 pspos:TEXCOORD2;		//позиция (для расчета координат отражения)
	float3 ViewDir:TEXCOORD3;	//направление от камера до вершины
};

Тут тоже ничего необычного, стандартные процедуры.

VS_OUTPUT vs_main(VS_INPUT IN)
{
	VS_OUTPUT OUT;
	//вычисляем позицию воды в экранной системе координат
	OUT.pos = mul( IN.pos,WVP );
	//направление взгляда
	OUT.ViewDir = EyePosition - mul( IN.pos,World);
	OUT.pspos = OUT.pos;
	//вычисляем новые текстурные координаты
	OUT.tex2.x = IN.texc.x + sin(time)*0.4;
	//т.е. смещаем данные текстурные координаты
	OUT.tex2.y = IN.texc.y - cos(time)*0.3;
	//для обеих карт по и против
	OUT.tex3.x = IN.texc.x - sin(time)*0.4;
	//часовой стрелки по окружности соответственно
	OUT.tex3.y = IN.texc.y + cos(time)*0.3;
	return OUT;
}

Вот и вершинный шейдер. В OUT.ViewDir сохраняем направление от камеры для каждой вершины квада воды (передаем его во фрагментный шейдер как текстурные координаты, следовательно, значения между вершинами будут интерполироваться). Далее текстурные координаты. У нас есть две текстуры (одна с картой нормалей, вторая со смещением текстурных координат). Чтобы вода "никуда не утекала" мы их передвигаем по кругу (sin/cos) в противоположенные стороны. Это один из самых распространенных вариантов скроллинга текстур. Вы можете их передвигать так, как вам вздумается, лишь бы результат устраивал.

float4 ps_main(float2 texCoord2: TEXCOORD0, float2 texCoord3: TEXCOORD1,
	float4 pspos: TEXCOORD2, float3 ViewDir:TEXCOORD3): COLOR
{
	float4 result;
	float3 colorOne = 2.0f * tex2D(bumpMap, texCoord2.xy) - 1.0f;
	float3 colorTwo = 2.0f * tex2D(bumpMap, texCoord3.xy*0.5) - 1.0f;
	float3 normal = normalize(colorOne+colorTwo + float3(0.0,0.5,0.0));
	float3 WorldNormal = normalize( mul( normal,(float3x3)World) );
	float3 texturecoordoffset = 2.0f * tex2D(bumpdudv1,texCoord2.xy) - 1.0f;
	float3 texturecoordoffset2= 2.0f * tex2D(bumpdudv1,texCoord3.xy*0.5) - 1.0f;
	float3 normaloffset = normalize(texturecoordoffset+texturecoordoffset2)*f_height_wave;
	pspos.xyz += normaloffset;
	float4 inTexProj = mul(pspos,Mr);
	result = tex2Dproj(tex, inTexProj);
	float3 LightDir = normalize( vec_light_dir );
	ViewDir = normalize( ViewDir );
	float4 I = min( dot(WorldNormal,-LightDir), 1.0f );
	// float3 Reflect = reflect(-LightDir,  WorldNormal);
	float3 Reflect = reflect(-LightDir,  1.14*I *WorldNormal);
  	float4 Specular = pow(0.5*(1.0+(dot(Reflect, ViewDir))) ,256);
	result = LightColor * MatColor * result * I + Specular*LightColor;
	result.a = 1.0;
	return result;
}

Во фрагментном шейдере считаем усредненную нормаль (и это делать вы можете так, как только вздумается, вариантов великое множество). Перед нормализацией (это все же нормаль) мы корректируем нормаль, чуть склоняя к вектору (0,1,0), водная поверхность ведь горизонтальная. Этого можно и не делать, но так результат выглядит лучше (конечно, можно отредактировать карту нормалей, но данный способ куда проще). Потом мы переводим нашу нормаль в мировые координаты (исключительно для освещения). То же самое что делали для нормали - делаем и для смещения текстурных координат (texturecoordoffset(2)). Далее, домножаем на коэффициент «высота волны». А дальше идет самое интересное: добавляем смещение к позиции водной поверхности в экранных координатах, домножаем все это на матрицу отражения и читаем из текстуры отражения с помощью tex2Dproj. Далее просто идет расчет освещения. Здесь также стоит поэкспериментировать с методами освещения и коэффициентами, вот наиболее интересные варианты:

technique tech0
{
	pass pass0
	{
		VertexShader = compile vs_2_0 vs_main();
		PixelShader  = compile ps_2_0 ps_main();
	}
};

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

3. Применение

Теперь рассмотрим применение нашего класса. Добавляем файл с классом воды:

Примечание. К исходному коду добавлен небольшой класс (EffectClass.h) для загрузки эффекта.
Класс не рассмотрен в статье, так как имеет к ней лишь косвенное отношение.

#include "water.h"
#include "EffectClass.h"

CWATER Water;
CEFFECT pActiveEffect;
float tt=0.0f;
IDirect3DTexture9* tex1;
IDirect3DTexture9* tex2;

Создаем воду и две текстуры (карту нормалей и карту смещений текстурных координат).

Функция рисования воды:

void DrawWater()
{
	tt +=DeltaTime*0.3f;
	D3DXMATRIX world,matView,matProj;
	D3DXMatrixTranslation	(&world, Water.ObjPos.x, Water.ObjPos.y, Water.ObjPos.z);
	ActiveCamera->GetViewMat(&matView);
	D3DDevice->GetTransform(D3DTS_PROJECTION,&matProj);

	pActiveEffect.AddToConstMatWVP("WVP",&world);
	pActiveEffect.AddToConstTexture("tex_tex",Water.tex_Reflect);
	pActiveEffect.AddToConstMat("World",&world);
	pActiveEffect.AddToConstVec3("LightColor",&D3DXVECTOR3(0.7f,0.7f,0.7f));
	pActiveEffect.AddToConstVec3("MatColor",&D3DXVECTOR3(0.7f,0.7f,0.7f));
	D3DXVECTOR3 dir=D3DXVECTOR3(0.0f,-1.0f,-1.0f);
	pActiveEffect.AddToConstVec3("vec_light_dir",&dir);
	pActiveEffect.AddToConstVec3("EyePosition",&ActiveCamera->GetCamPos());
	pActiveEffect.AddToConstFloat("time",tt);
	pActiveEffect.AddToConstFloat("f_height_wave",2.0f);
	pActiveEffect.AddToConstTexture("tex_bumpMap",tex1);
	pActiveEffect.AddToConstTexture("tex_bumpdudv1",tex2);

	pActiveEffect.Begin();
	pActiveEffect.Start(0);
	Water.Draw();
	pActiveEffect.Finish();
	pActiveEffect.End();
}

tt – глобальная переменная, которая «растет» с каждым кадром. От нее зависит скорость перемещения камеры, скорость движения волн и т.д. (она символизирует время). Все что идет до pActiveEffect.Begin() – это просто заполнение данных для шейдера, так что здесь останавливать незачем.

	pActiveEffect.Begin();
	pActiveEffect.Start(0);
	pActiveEffect.Finish();
	pActiveEffect.End();

Эта комбинация лишь задает активную технику и проход.
Water.Draw() вызывает отрисовку воды.

В функции инициализации приложения добавляем следующий код:

	Water.Init(400.0f, 400.0f, 10.0f);
	Water.ObjPos = D3DXVECTOR3(-200.0f,-10.0f,-200.0f);
	pActiveEffect.Init();
	D3DXCreateTextureFromFile( D3DDevice, "water_bump.jpg", &tex1 );
	D3DXCreateTextureFromFile( D3DDevice, "dudv.jpg", &tex2 );

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

В функции удаления не забываем очистить ресурсы (текстуры).

	tex1->Release();
	tex2->Release();

Ну и в функции рисования:

	_РИСУЕМ_ВСЕ_ОБЪЕКТЫ_СЦЕНЫ_КРОМЕ_ВОДЫ_
	Water.PreRenderForReflection(ActiveCamera);
	_РИСУЕМ_ВСЕ_ОБЪЕКТЫ_СЦЕНЫ_КРОМЕ_ВОДЫ_
	Water.PostRenderForReflection(ActiveCamera);
	DrawWater();

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

Вот и все!

4. Исходный код

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

Исполняемый файл и исходники (DirectX, C++): water_in3Dapps.zip (0.4 Мб)

 


 

Статья участвовала в конкурсе статей по программированию #2 (2007).