Вступление
Привет!
Недавно начал экспериментировать с процедурной генерацией и получил некоторые наработки, с которыми и хотелось бы поделится. Примеры я буду показывать на движке Godot, однако при надобности код можно перенести на любой другой современный движок.
Начало работы
Создадим новый проект и скачаем плагин Heightmap Terrain из AssetLib - он уже содержит всё необходимое для базовой работы с ландшафтами. Не забудем также включить плагин в настройках проекта.
Если всё сделано правильно, то в списке узлов появится HTerrain. Данный узел как можно догадаться, позволяет создавать ландшафты.
А как вообще система представляет для себя ландшафт? Он хранится в виде текстур: карты высот (Height Map), карты нормалей (Normal Map), карты смешивания текстур поверхностей (Splat Map), а также эти самые текстуры поверхностей.
Значит, чтобы сгенерировать ландшафт, нам нужно просто сгенерировать карту высот и в зависимости от неё все остальные текстуры? Мы можем выполнять этот процесс в обычном коде, но для больших Terrain, разрешением, допустим, больше 512x512, производительности процессора будет уже не хватать. И тут нам на помощь приходят видеокарта. Для этой задачи хорошо подходят обычные шейдеры.
Основа
Так как нам нужна 2D картинка мы будем использовать CanvasItem шейдеры. Создадим какой-нибудь узел, наследующийся от CanvasItem. Здесь подойдёт ColorRect, так как нам нужен просто какой-нибудь прямоугольник. Зададим ему размер 4097x4097 пикселей, так как это максимальный размер, поддерживаемый плагином.
В свойство Material добавим новый ShaderMaterial.
Наш прямоугольник должен стать прозрачным. Добавим базовый код:
shader_type canvas_item;
void fragment() {
}
В нашем случае функция fragment() вызывается для каждой точки изображения. Например, такой код зальёт всё изображение жёлтым.
shader_type canvas_item;
void fragment() {
COLOR.rgb = vec3(1.0, 1.0, 0.0);
}
Переменная UV содержит позицию точки, причём в диапазонах от нуля до единицы. Как можно заметить, все составляющие векторных переменных можно перемешивать, получая комбинации в любом порядке. Здесь можно было также использовать UV.x, UV.yx, UV.yy и так далее. Это всё давало бы разные интересные результаты.
shader_type canvas_item;
void fragment() {
COLOR.rg = UV.xy;
}
Но вернёмся к нашей теме - генерации ландшафтов. Начнём генерацию карты высот. карта высот использует только один канал R. Для удобства мы будем использовать значения от нуля до единицы.
Для начала сделаем заготовку острова - чем дальше точка от центра изображения - тем значение меньше.
shader_type canvas_item;
void fragment() {
float dist = distance(UV, vec2(0.5, 0.5));
float height = 1.0 - dist / 0.5;
COLOR.rgb = vec3(height, 0.0, 0.0);
}
Шум
Теперь нужно как-то разнообразить наш ландшафт. Для этого можно комбинировать несколько слоев шума. Существует множество реализаций разных шумов, я буду использовать простой Simplex Noise отсюда:
https://github.com/curly-brace/Godot-3.0-Noise-Shaders
Добавим код шума в начало шейдера до функции fragment()
shader_type canvas_item;
uniform vec2 offset;
vec3 mod289_3(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec2 mod289_2(vec2 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec3 permute(vec3 x) {
return mod289_3(((x*34.0)+1.0)*x);
}
float snoise(vec2 v) {
vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0
0.366025403784439, // 0.5*(sqrt(3.0)-1.0)
-0.577350269189626, // -1.0 + 2.0 * C.x
0.024390243902439); // 1.0 / 41.0
// First corner
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
// Other corners
vec2 i1;
//i1.x = step( x0.y, x0.x ); // x0.x > x0.y ? 1.0 : 0.0
//i1.y = 1.0 - i1.x;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
// x0 = x0 - 0.0 + 0.0 * C.xx ;
// x1 = x0 - i1 + 1.0 * C.xx ;
// x2 = x0 - 1.0 + 2.0 * C.xx ;
vec4 x12 = vec4(x0.xy, x0.xy) + C.xxzz;
x12.xy -= i1;
// Permutations
i = mod289_2(i); // Avoid truncation effects in permutation
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), vec3(0.0));
m = m*m ;
m = m*m ;
// Gradients: 41 points uniformly over a line, mapped onto a diamond.
// The ring size 17*17 = 289 is close to a multiple of 41 (41*7 = 287)
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
// Normalise gradients implicitly by scaling m
// Approximation of: m *= inversesqrt( a0*a0 + h*h );
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
// Compute final noise value at P
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
void fragment() {
float dist = distance(UV, vec2(0.5, 0.5));
float height = 1.0 - dist / 0.5;
COLOR.rgb = vec3(height, 0.0, 0.0);
}
Теперь мы можем использовать функцию snoise(). Также из редактора доступно свойство offset. Создадим вспомогательную функцию get_height(), которая будет возвращать высоту в точке.
...
float get_height(float x, float y) {
float base_noise = snoise((vec2(x, y) + offset) * 2.0) * 0.5 + 0.5;
float dist = distance(vec2(x, y), vec2(0.5, 0.5));
float height = 1.0 - dist / 0.5;
return height + base_noise / 4.0;
}
void fragment() {
COLOR.rgb = vec3(get_height(UV.x, UV.y), 0.0, 0.0);
}
Меняя свойство offset мы можем получить другой результат.
Рендер в HTerrain
Для начала работы, такой картинки будет достаточно. Теперь нужно перенести изображение из нашего шейдера в сам ландшафт. Для этого поместим наш TextureRect в отдельный Viewport. При этом вне Viewport можно оставить копию для отладки.
Зададим размер viewport равный размеру TextureRect. Также необходимо включить поддержку HDR, которая позволяет принимать значения за пределами нуля и единицы - это нужно для карты высот. Update Mode ставим на Disabled, т.к. мы будем обновлять Viewport из кода только один раз чтобы получить нашу картинку. Usage установим на 3D No-Effect, т.к. 3D необходимо для HDR.
Теперь создадим некий объект, который будет отвечать за генерацию. Прикрепим к нему скрипт.
extends Node
# Импортируем необходимые классы из плагина
const HTerrain = preload("res://addons/zylann.hterrain/hterrain.gd")
const HTerrainData = preload("res://addons/zylann.hterrain/hterrain_data.gd")
const HTerrainTextureSet = preload("res://addons/zylann.hterrain/hterrain_texture_set.gd")
# Нам необходим объект Viewport с нашим TextureRect
export (NodePath) var viewport_path :NodePath
onready var viewport :Viewport = get_node(viewport_path)
export (NodePath) var shader_node_path :NodePath
onready var shader_node :CanvasItem = get_node(shader_node_path)
func _ready():
randomize()
# Зададим случайное смещение параметра offse шейдера, для того чтобы при каждом запуске получать разные результаты
shader_node.material.set_shader_param("offset", Vector2(rand_range(-100.0, 100.0), rand_range(-100, 100)))
# Создадим объект с данными Terrain
var terrain_data = HTerrainData.new()
terrain_data.resize(4097)
# Получим изображение, которое нужно заменить нашим Heightmap
var heightmap :Image = terrain_data.get_image(HTerrainData.CHANNEL_HEIGHT)
# Заставим Viewport обновиться
viewport.render_target_update_mode = Viewport.UPDATE_ONCE
# Благодаря этой конструкции мы можем пропустить выполнение пары кадров для этого кода, чтобы Viewport успел зарендерить картинку.
yield(get_tree(), "idle_frame")
yield(get_tree(), "idle_frame")
# Получим картинку с Viewport
var computed_heightmap :Image = viewport.get_texture().get_data()
# Заменим пустую картинку в нашем Terrain Data полученным Heightmap
heightmap.copy_from(computed_heightmap)
# Создаём узел Terrain
var terrain = HTerrain.new()
terrain.set_shader_type(HTerrain.SHADER_CLASSIC4_LITE)
terrain.set_data(terrain_data)
terrain.translation = Vector3(-2048.5, -25, -2048.5)
# Добавим узел Terrain на сцену
add_child(terrain)
Добавим на сцену узел Camera.
На него можем прикрепить простой скрипт для передвижения, взятый отсюда: https://github.com/adamviola/simple-free-look-camera
class_name FreelookCamera extends Camera
export(float, 0.0, 1.0) var sensitivity = 0.25
# Mouse state
var _mouse_position = Vector2(0.0, 0.0)
var _total_pitch = 0.0
# Movement state
var _direction = Vector3(0.0, 0.0, 0.0)
var _velocity = Vector3(0.0, 0.0, 0.0)
var _acceleration = 30
var _deceleration = -10
var _vel_multiplier = 4
# Keyboard state
var _w = false
var _s = false
var _a = false
var _d = false
var _q = false
var _e = false
func _input(event):
# Receives mouse motion
if event is InputEventMouseMotion:
_mouse_position = event.relative
# Receives mouse button input
if event is InputEventMouseButton:
match event.button_index:
BUTTON_RIGHT: # Only allows rotation if right click down
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED if event.pressed else Input.MOUSE_MODE_VISIBLE)
BUTTON_WHEEL_UP: # Increases max velocity
_vel_multiplier = clamp(_vel_multiplier * 1.1, 0.2, 20)
BUTTON_WHEEL_DOWN: # Decereases max velocity
_vel_multiplier = clamp(_vel_multiplier / 1.1, 0.2, 20)
# Receives key input
if event is InputEventKey:
match event.scancode:
KEY_W:
_w = event.pressed
KEY_S:
_s = event.pressed
KEY_A:
_a = event.pressed
KEY_D:
_d = event.pressed
KEY_Q:
_q = event.pressed
KEY_E:
_e = event.pressed
# Updates mouselook and movement every frame
func _process(delta):
_update_mouselook()
_update_movement(delta)
# Updates camera movement
func _update_movement(delta):
# Computes desired direction from key states
_direction = Vector3(_d as float - _a as float,
_e as float - _q as float,
_s as float - _w as float)
# Computes the change in velocity due to desired direction and "drag"
# The "drag" is a constant acceleration on the camera to bring it's velocity to 0
var offset = _direction.normalized() * _acceleration * _vel_multiplier * delta \
+ _velocity.normalized() * _deceleration * _vel_multiplier * delta
# Checks if we should bother translating the camera
if _direction == Vector3.ZERO and offset.length_squared() > _velocity.length_squared():
# Sets the velocity to 0 to prevent jittering due to imperfect deceleration
_velocity = Vector3.ZERO
else:
# Clamps speed to stay within maximum value (_vel_multiplier)
_velocity.x = clamp(_velocity.x + offset.x, -_vel_multiplier, _vel_multiplier)
_velocity.y = clamp(_velocity.y + offset.y, -_vel_multiplier, _vel_multiplier)
_velocity.z = clamp(_velocity.z + offset.z, -_vel_multiplier, _vel_multiplier)
translate(_velocity * delta)
# Updates mouse look
func _update_mouselook():
# Only rotates mouse if the mouse is captured
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
_mouse_position *= sensitivity
var yaw = _mouse_position.x
var pitch = _mouse_position.y
_mouse_position = Vector2(0, 0)
# Prevents looking up/down too far
pitch = clamp(pitch, -90 - _total_pitch, 90 - _total_pitch)
_total_pitch += pitch
rotate_y(deg2rad(-yaw))
rotate_object_local(Vector3(1,0,0), deg2rad(-pitch))
Для Terrain Generator зададим пути в инспекторе к остальным узлам.
Не забудем скрыть отладочный ColorRect, иначе он будет мешать.
Рекомендую также увеличить свойство Far камеры, иначе мы не будем видеть весь Terrain.
При запуске на первый взгляд видна просто белая плоскость. Дело в том, что мы выводили значения только от нуля до единицы. Необходимо это исправить.
Вернёмся к шейдеру и добавим переменную max_height.
shader_type canvas_item;
uniform vec2 offset = vec2(0, 0);
uniform float max_height = 1.0;
В fragment() добавим умножение на max_height.
void fragment() {
COLOR.rgb = vec3(get_height(UV.x, UV.y) * max_height, 0.0, 0.0);
}
У ColorRect, находящегося в Viewport укажем max_height в редакторе на 300 (к примеру).
Добавим на сцену также Directional Light с Shadow/Enabled = True.
На данный момент результат выглядит как-то так.
Для лучшего понимания в редакторе добавим Plane MeshInstance, который будет показывать уровень воды.
Артефакты можно убрать увеличив Near камеры.
Вернёмся к шейдеру. Попробуем комбинировать несколько слоев шума чтобы получать лучший результат.
...
float get_height(float x, float y) {
float base_noise = snoise((vec2(x, y) + offset) * 2.0) * 0.5 + 0.5;
float dist = distance(vec2(x, y), vec2(0.5, 0.5));
float inv_dist = 1.0 - dist / 0.5;
float base = inv_dist / 0.25 * base_noise;
float layer_noise = snoise((vec2(x, y) + offset) * 10.0) * 0.5;
float result = base / 4.0 + layer_noise / 60.0;
return result;
}
void fragment() {
COLOR.rgb = vec3(get_height(UV.x, UV.y) * max_height, 0.0, 0.0);
}
Это уже больше похоже на остров.
Normal Map
Теперь нужно сгенерировать все остальные текстуры для terrain. Добавим переменную, которая будет показывать какую именно текстуру нужно сгенерировать.
0 - Height Map
1 - Normal Map
2 - Splat Map
shader_type canvas_item;
uniform vec2 offset = vec2(0, 0);
uniform float max_height = 1.0;
uniform int texture_type = 0;
...
Добавим в код функцию encode_normal()
vec3 encode_normal(vec3 n){
return (0.5 * (n + vec3(1.0))).rbg;
}
Получим нормаль прямо из шума и выведем её при texture_type == 1.
...
vec3 encode_normal(vec3 n){
return (0.5 * (n + vec3(1.0))).rbg;
}
float get_height(float x, float y) {
float base_noise = snoise((vec2(x, y) + offset) * 2.0) * 0.5 + 0.5;
float dist = distance(vec2(x, y), vec2(0.5, 0.5));
float inv_dist = 1.0 - dist / 0.5;
float base = inv_dist / 0.25 * base_noise;
float layer_noise = snoise((vec2(x, y) + offset) * 10.0) * 0.5;
float result = base / 4.0 + layer_noise / 60.0;
return result;
}
void fragment() {
float height = get_height(UV.x, UV.y);
float real_height = height * max_height;
float h_right = max_height * get_height(UV.x + 0.0000244140625, UV.y);
float h_forward = max_height * get_height(UV.x, UV.y + 0.0000244140625);
vec3 normal = normalize(vec3(real_height - h_right, 0.1, h_forward - real_height));
switch (texture_type){
case 0:
COLOR.rgb = vec3(real_height, 0.0, 0.0);
break;
case 1:
COLOR.rgb = encode_normal(normal);
break;
}
}
Результат при texture_type == 1.
Splat Map
При генерации Splat Map появляется первая серьёзная проблема. Каждый пиксель Splat Map представляет вес четырёх текстур в виде значений четырёх каналов R, G, B, A. Но мы не можем правильно передать текстуру с прозрачностью из шейдера, так как при считывании значение A приближается к значение R, G, B и изначальное значение теряется. Поэтому я отдельно рендерю карту с R, G, B, а затем карту со значениями A. Затем они объединяются со стороны GDScript. Это очень сильно снижает производительность, но мне пока что не приходит на ум лучшего решения без изменения кода плагина.
2 = Splat Map
3 = Splat Map A
Можно высчитывать значения Splat Map в зависимости от высоты и нормали. Triplanar Mapping поддерживается только для четвертого канала, поэтому для склонов мы будем использовать его. Хотя в моем случае склонов обычно не генерируется.
R = Земля
G = Песок
B =
A = Склоны
vec3 encode_normal(vec3 n){
return (0.5 * (n + vec3(1.0))).rbg;
}
vec4 linear_interpolate(vec4 a, vec4 b, float ammount){
return a + (b - a) * ammount;
}
float get_height(float x, float y) {
float base_noise = snoise((vec2(x, y) + offset) * 2.0) * 0.5 + 0.5;
float dist = distance(vec2(x, y), vec2(0.5, 0.5));
float inv_dist = 1.0 - dist / 0.5;
float base = inv_dist / 0.25 * base_noise;
float layer_noise = snoise((vec2(x, y) + offset) * 10.0) * 0.5;
float result = base / 4.0 + layer_noise / 60.0;
return result;
}
void fragment() {
float height = get_height(UV.x, UV.y);
float real_height = height * max_height;
float h_right = max_height * get_height(UV.x + 0.0000244140625, UV.y);
float h_forward = max_height * get_height(UV.x, UV.y + 0.0000244140625);
vec3 normal = normalize(vec3(real_height - h_right, 0.1, h_forward - real_height));
vec4 splat = vec4(1.0, 0.0, 0.0, 0.0);
float slope = 4.0 * dot(normal, vec3(0.0, 1.0, 0.0)) - 2.0;
float slope_amount = clamp(1.0 - slope, 0.0, 1.0);
float sand_amount = clamp(30.0 - real_height, 0.0, 1.0);
splat = linear_interpolate(splat, vec4(0.0,1.0,0.0,0.0), sand_amount);
splat = linear_interpolate(splat, vec4(0.0,0.0,0.0,1.0), slope_amount);
switch (texture_type){
case 0:
COLOR.rgb = vec3(real_height, 0.0, 0.0);
break;
case 1:
COLOR.rgb = encode_normal(normal);
break;
case 2:
COLOR.rgb = splat.rgb;
break;
case 3:
COLOR.rgb = vec3(splat.a, 0, 0);
break;
}
}
texture_type = 2.
texture_type = 3
Если сделать значения высоты сильно резче:
Вернёмся к части GDScript.
extends Node
# Импортируем необходимые классы из плагина
const HTerrain = preload("res://addons/zylann.hterrain/hterrain.gd")
const HTerrainData = preload("res://addons/zylann.hterrain/hterrain_data.gd")
const HTerrainTextureSet = preload("res://addons/zylann.hterrain/hterrain_texture_set.gd")
# Набор текстур Terrain созданный в редакторе
const texture_set = preload("res://terrain_texture_set.tres")
# Нам необходим объект Viewport с нашим TextureRect
export (NodePath) var viewport_path :NodePath
onready var viewport :Viewport = get_node(viewport_path)
export (NodePath) var shader_node_path :NodePath
onready var shader_node :CanvasItem = get_node(shader_node_path)
func _ready():
randomize()
# Зададим случайное смещение параметра offse шейдера, для того чтобы при каждом запуске получать разные результаты
shader_node.material.set_shader_param("offset", Vector2(rand_range(-100.0, 100.0), rand_range(-100, 100)))
# Создадим объект с данными Terrain
var terrain_data = HTerrainData.new()
terrain_data.resize(4097)
# Получим изображения
var heightmap :Image = terrain_data.get_image(HTerrainData.CHANNEL_HEIGHT)
var normalmap :Image = terrain_data.get_image(HTerrainData.CHANNEL_NORMAL)
var splatmap :Image = terrain_data.get_image(HTerrainData.CHANNEL_SPLAT)
# --------
# Укажем шейдеру какую текстуру мы хотим получить
shader_node.material.set_shader_param("texture_type", 0)
# Заставим Viewport обновиться
viewport.render_target_update_mode = Viewport.UPDATE_ONCE
# Благодаря этой конструкции мы можем пропустить выполнение пары кадров для этого кода, чтобы Viewport успел зарендерить картинку.
yield(get_tree(), "idle_frame")
yield(get_tree(), "idle_frame")
# Получим картинку с Viewport
var computed_heightmap :Image = viewport.get_texture().get_data()
# --------
# Укажем шейдеру какую текстуру мы хотим получить
shader_node.material.set_shader_param("texture_type", 1)
# Заставим Viewport обновиться
viewport.render_target_update_mode = Viewport.UPDATE_ONCE
# Благодаря этой конструкции мы можем пропустить выполнение пары кадров для этого кода, чтобы Viewport успел зарендерить картинку.
yield(get_tree(), "idle_frame")
yield(get_tree(), "idle_frame")
# Получим картинку с Viewport
var computed_normalmap :Image = viewport.get_texture().get_data()
# --------
# Укажем шейдеру какую текстуру мы хотим получить
shader_node.material.set_shader_param("texture_type", 2)
# Заставим Viewport обновиться
viewport.render_target_update_mode = Viewport.UPDATE_ONCE
# Благодаря этой конструкции мы можем пропустить выполнение пары кадров для этого кода, чтобы Viewport успел зарендерить картинку.
yield(get_tree(), "idle_frame")
yield(get_tree(), "idle_frame")
# Получим картинку с Viewport
var computed_splatmap :Image = viewport.get_texture().get_data()
# --------
# Укажем шейдеру какую текстуру мы хотим получить
shader_node.material.set_shader_param("texture_type", 3)
# Заставим Viewport обновиться
viewport.render_target_update_mode = Viewport.UPDATE_ONCE
# Благодаря этой конструкции мы можем пропустить выполнение пары кадров для этого кода, чтобы Viewport успел зарендерить картинку.
yield(get_tree(), "idle_frame")
yield(get_tree(), "idle_frame")
# Получим картинку с Viewport
var computed_splatmap_a :Image = viewport.get_texture().get_data()
# --------
# Объединим Splat Map RGB и Splat Map A
computed_splatmap.lock()
computed_splatmap_a.lock()
for x in range(computed_splatmap.get_width()):
for y in range(computed_splatmap.get_height()):
var p :Color = computed_splatmap.get_pixel(x, y)
p.a = computed_splatmap_a.get_pixel(x, y).r;
computed_splatmap.set_pixel(x, y, p)
computed_splatmap.unlock()
computed_splatmap_a.unlock()
# Вернём полученные текстуры в Terrain Data
heightmap.copy_from(computed_heightmap)
normalmap.copy_from(computed_normalmap)
splatmap.copy_from(computed_splatmap)
# Создаём узел Terrain
var terrain = HTerrain.new()
terrain.set_shader_type(HTerrain.SHADER_CLASSIC4_LITE)
terrain.set_shader_param("u_triplanar", true)
terrain.set_shader_param("u_tile_reduction", Quat(1.0, 1.0, 1.0, 1.0))
terrain.set_shader_param("u_depth_blending", true)
terrain.set_texture_set(texture_set)
terrain.set_data(terrain_data)
terrain.translation = Vector3(-2048.5, -25, -2048.5)
# Добавим узел Terrain на сцену
add_child(terrain)
Здесь было бы неплохо объединить четыре одинаковых фрагмента получения текстур в функцию, однако это не получается сделать из-за использования yield(), который завершает выполнение функции и возвращает значение указывающее какой сейчас оператор выполняется.
Также появилась переменная texture_set. Нужно создать TextureSet в редакторе.
Добавим в проект текстуры.
Создадим узел HTerrain в редакторе, чтобы получить доступ к его инструментам.
Нажмём на Import... в окне управления текстурами
Устанавливаем все четыре набора текстур и импортируем.
В инспекторе выберем ресурс из свойства Texture Set и сохраним его в файл "terrain_texture_set.tres"
Удаляем ненужный HTerrain и тестируем проект.
Если сделать вывод функции get_height() более резким, то появляются склоны.
// 2.0 -> 20.0
float base_noise = snoise((vec2(x, y) + offset) * 20.0) * 0.5 + 0.5;
Теперь можно создать более реалистичную воду. Я использовал этот шейдер: https://github.com/godot-extended-libraries/godot-realistic-water
Результат:
Понравилась статья?
Здесь вы можете поддержать меня, а также скачать готовый проект:
Комментариев нет:
Отправить комментарий