Разработка 3D движка на C: от математики до оптимизации рендеринга

Пройдите тест, узнайте какой профессии подходите
Сколько вам лет
0%
До 18
От 18 до 24
От 25 до 34
От 35 до 44
От 45 до 49
От 50 до 54
Больше 55

Для кого эта статья:

  • Разработчики, интересующиеся созданием 3D графики и игрового движка
  • Студенты и аспиранты в области компьютерных наук и программирования
  • Специалисты по тестированию ПО, желающие освоить графическое программирование

    Создание 3D движка на языке C — это как собирать механические часы вручную в эпоху умных устройств. Такое мастерство даёт глубокое понимание принципов компьютерной графики и механизмов, скрытых под капотом современных игровых движков. В этом руководстве мы пройдём весь путь от математических основ до создания работающего 3D движка, способного рендерить трёхмерные объекты с освещением и текстурами. Готовы погрузиться в мир вершин, полигонов и шейдеров? 🚀

Разработка 3D движка требует системного мышления и внимания к деталям — качеств, необходимых любому специалисту по тестированию ПО. На Курсе тестировщика ПО от Skypro вы научитесь не только находить ошибки в сложных системах, но и понимать архитектуру приложений на глубинном уровне. Знания программирования и тестирования графических приложений откроют перед вами двери в геймдев и сферу 3D-моделирования.

Основы архитектуры 3D движка на языке C

Архитектура 3D движка — это набор компонентов, которые работают вместе для преобразования 3D моделей в пиксели на экране. Создавая движок на C, мы получаем полный контроль над низкоуровневыми операциями и максимальную производительность. 💻

Основные компоненты 3D движка включают:

  • Система управления окнами — для создания контекста отрисовки и обработки ввода
  • Математическая библиотека — для операций с векторами, матрицами и кватернионами
  • Модуль рендеринга — сердце движка, отвечающее за отрисовку 3D объектов
  • Система управления ресурсами — загрузка моделей, текстур и шейдеров
  • Модуль освещения — расчёт освещённости сцены
  • Система камер — управление перспективой и точкой зрения

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

Компонент Библиотека Назначение
Оконная система GLFW Создание окна, обработка ввода
Графический API OpenGL Рендеринг 3D объектов
Математика GLM (или собственная) Векторные и матричные вычисления
Загрузка моделей Assimp (опционально) Импорт 3D моделей из различных форматов
Загрузка текстур stb_image Декодирование изображений в форматах PNG, JPEG и др.

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

Александр Петров, Lead Graphics Programmer

Помню свой первый опыт создания 3D движка на C. Это был проект для университета, и я решил, что OpenGL — это слишком просто, поэтому попытался писать рендеринг с нуля. Две недели я бился над растеризацией треугольников, пытаясь оптимизировать алгоритмы до предела. Когда наконец заставил это работать, понял, что текстурирование и освещение займут ещё больше времени. В итоге вернулся к OpenGL, но тот опыт дал мне глубокое понимание того, что происходит "под капотом" графических API. Сегодня я рекомендую начинающим не изобретать велосипед, а использовать готовые решения для низкоуровневых операций, сосредоточившись на архитектуре и более высокоуровневых компонентах.

Вот базовая структура нашего проекта:

c
Скопировать код
// engine.h – главный заголовочный файл
typedef struct {
// Состояние окна
GLFWwindow* window;
int width, height;

// Ресурсы
struct Model* models;
struct Texture* textures;

// Камера и сцена
struct Camera camera;
struct Light lights[MAX_LIGHTS];

// Рендеринг
struct Shader* shaders;
} Engine;

// Инициализация и завершение работы
Engine* engine_init(int width, int height, const char* title);
void engine_shutdown(Engine* engine);

// Основной цикл
void engine_run(Engine* engine);

Пошаговый план для смены профессии

Математический фундамент для программирования 3D графики

Математика — это язык, на котором говорит компьютерная графика. Без понимания векторов, матриц и тригонометрии создать 3D движок невозможно. 📐

Ключевые математические концепции, необходимые для 3D движка:

  • Векторы — для представления позиций, направлений и цветов
  • Матрицы — для трансформаций объектов в пространстве
  • Кватернионы — для представления вращений без проблемы "шарнирного замка"
  • Проекции — для преобразования 3D координат в 2D экранные координаты

Начнём с реализации базовых векторных операций:

c
Скопировать код
// math_3d.h
typedef struct {
float x, y, z;
} Vec3;

// Сложение векторов
Vec3 vec3_add(Vec3 a, Vec3 b) {
Vec3 result = {a.x + b.x, a.y + b.y, a.z + b.z};
return result;
}

// Вычитание векторов
Vec3 vec3_sub(Vec3 a, Vec3 b) {
Vec3 result = {a.x – b.x, a.y – b.y, a.z – b.z};
return result;
}

// Скалярное произведение
float vec3_dot(Vec3 a, Vec3 b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}

// Векторное произведение
Vec3 vec3_cross(Vec3 a, Vec3 b) {
Vec3 result = {
a.y * b.z – a.z * b.y,
a.z * b.x – a.x * b.z,
a.x * b.y – a.y * b.x
};
return result;
}

// Нормализация вектора
Vec3 vec3_normalize(Vec3 v) {
float length = sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
Vec3 result = {v.x / length, v.y / length, v.z / length};
return result;
}

Для работы с трансформациями необходимы матрицы 4x4:

c
Скопировать код
typedef struct {
float m[4][4];
} Mat4;

// Создание единичной матрицы
Mat4 mat4_identity() {
Mat4 result = {{{
{1.0f, 0.0f, 0.0f, 0.0f},
{0.0f, 1.0f, 0.0f, 0.0f},
{0.0f, 0.0f, 1.0f, 0.0f},
{0.0f, 0.0f, 0.0f, 1.0f}
}}};
return result;
}

// Умножение матриц
Mat4 mat4_multiply(Mat4 a, Mat4 b) {
Mat4 result = {{{0}}};

for(int i = 0; i < 4; i++) {
for(int j = 0; j < 4; j++) {
for(int k = 0; k < 4; k++) {
result.m[i][j] += a.m[i][k] * b.m[k][j];
}
}
}

return result;
}

Особенно важны матрицы трансформации — перемещения, вращения и масштабирования:

c
Скопировать код
// Матрица перемещения
Mat4 mat4_translation(float x, float y, float z) {
Mat4 result = mat4_identity();
result.m[0][3] = x;
result.m[1][3] = y;
result.m[2][3] = z;
return result;
}

// Матрица вращения вокруг оси X
Mat4 mat4_rotation_x(float angle_rad) {
Mat4 result = mat4_identity();
float c = cos(angle_rad);
float s = sin(angle_rad);

result.m[1][1] = c;
result.m[1][2] = -s;
result.m[2][1] = s;
result.m[2][2] = c;

return result;
}

// Аналогично для осей Y и Z...

Матрица Назначение Применение в движке
Модельная (Model) Позиционирование объекта в мировом пространстве Перемещение, вращение и масштабирование объектов
Видовая (View) Позиционирование камеры Определение точки зрения на сцену
Проекционная (Projection) Преобразование 3D в 2D Создание перспективы и глубины
MVP (Model-View-Projection) Комбинирование всех трансформаций Итоговое преобразование для отрисовки

Создание рендеринг-цикла и настройка оконной системы

Рендеринг-цикл — это сердце нашего движка, которое поддерживает постоянное обновление и отображение 3D сцены. Для начала нужно создать окно и настроить OpenGL контекст. 🖼️

Инициализация GLFW и создание окна:

c
Скопировать код
Engine* engine_init(int width, int height, const char* title) {
Engine* engine = malloc(sizeof(Engine));

// Инициализация GLFW
if (!glfwInit()) {
fprintf(stderr, "Failed to initialize GLFW\n");
free(engine);
return NULL;
}

// Настройка OpenGL
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

// Создание окна
engine->window = glfwCreateWindow(width, height, title, NULL, NULL);
if (!engine->window) {
fprintf(stderr, "Failed to create GLFW window\n");
glfwTerminate();
free(engine);
return NULL;
}

// Установка контекста
glfwMakeContextCurrent(engine->window);

// Инициализация GLAD для загрузки функций OpenGL
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
fprintf(stderr, "Failed to initialize GLAD\n");
glfwDestroyWindow(engine->window);
glfwTerminate();
free(engine);
return NULL;
}

// Настройка вьюпорта
glViewport(0, 0, width, height);

// Инициализация других подсистем...

return engine;
}

Теперь реализуем основной рендеринг-цикл:

c
Скопировать код
void engine_run(Engine* engine) {
// Цикл выполняется, пока окно не закрыто
while (!glfwWindowShouldClose(engine->window)) {
// Очистка буфера
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// Обработка ввода
process_input(engine);

// Обновление состояния сцены
update_scene(engine);

// Рендеринг объектов
render_scene(engine);

// Обмен буферов и обработка событий
glfwSwapBuffers(engine->window);
glfwPollEvents();
}
}

Максим Иванов, Game Engine Developer

Однажды я разрабатывал 3D движок для визуализации архитектурных проектов. Клиенту нужно было, чтобы пользователи могли "гулять" по зданию в режиме реального времени. Я настроил простой рендеринг-цикл, но обнаружил странную проблему: движение было плавным на одних компьютерах и дёрганым на других. Оказалось, что я привязал скорость перемещения камеры к частоте кадров. На мощных машинах с 120+ FPS объекты двигались вдвое быстрее, чем на стандартных с 60 FPS! Решением стало введение временной дельты между кадрами: каждое перемещение умножалось на время, прошедшее с предыдущего кадра. Это важный урок: никогда не привязывайте логику движения к частоте рендеринга.

Важно правильно обрабатывать пользовательский ввод:

c
Скопировать код
void process_input(Engine* engine) {
if(glfwGetKey(engine->window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(engine->window, 1);

// Управление камерой
if(glfwGetKey(engine->window, GLFW_KEY_W) == GLFW_PRESS)
camera_move_forward(&engine->camera);
if(glfwGetKey(engine->window, GLFW_KEY_S) == GLFW_PRESS)
camera_move_backward(&engine->camera);
// И так далее для других клавиш...
}

Для рендеринга объектов необходимо создать буферы вершин и индексов:

c
Скопировать код
typedef struct {
unsigned int VAO, VBO, EBO;
int vertex_count, index_count;
} Mesh;

Mesh* create_mesh(float* vertices, int vertex_count, 
unsigned int* indices, int index_count) {
Mesh* mesh = malloc(sizeof(Mesh));
mesh->vertex_count = vertex_count;
mesh->index_count = index_count;

// Создание буферов
glGenVertexArrays(1, &mesh->VAO);
glGenBuffers(1, &mesh->VBO);
glGenBuffers(1, &mesh->EBO);

// Привязка VAO
glBindVertexArray(mesh->VAO);

// Загрузка вершин
glBindBuffer(GL_ARRAY_BUFFER, mesh->VBO);
glBufferData(GL_ARRAY_BUFFER, vertex_count * sizeof(float), 
vertices, GL_STATIC_DRAW);

// Загрузка индексов
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh->EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, 
index_count * sizeof(unsigned int), 
indices, GL_STATIC_DRAW);

// Настройка атрибутов вершин
// Позиции
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 
8 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// Нормали
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 
8 * sizeof(float), 
(void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

// Текстурные координаты
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 
8 * sizeof(float), 
(void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);

// Отвязка VAO
glBindVertexArray(0);

return mesh;
}

Реализация ключевых модулей: камера, освещение, текстуры

Теперь создадим основные модули, необходимые для полноценного 3D движка: камеру для просмотра сцены, систему освещения и работу с текстурами. 🔦

Начнем с реализации камеры:

c
Скопировать код
typedef struct {
Vec3 position;
Vec3 front;
Vec3 up;
Vec3 right;
float yaw;
float pitch;
} Camera;

void camera_init(Camera* camera) {
camera->position = (Vec3){0.0f, 0.0f, 3.0f};
camera->front = (Vec3){0.0f, 0.0f, -1.0f};
camera->up = (Vec3){0.0f, 1.0f, 0.0f};
camera->right = (Vec3){1.0f, 0.0f, 0.0f};
camera->yaw = -90.0f;
camera->pitch = 0.0f;
}

void camera_update_vectors(Camera* camera) {
// Вычисляем новый вектор направления
Vec3 front;
front.x = cos(to_radians(camera->yaw)) * cos(to_radians(camera->pitch));
front.y = sin(to_radians(camera->pitch));
front.z = sin(to_radians(camera->yaw)) * cos(to_radians(camera->pitch));
camera->front = vec3_normalize(front);

// Пересчитываем правый и верхний векторы
camera->right = vec3_normalize(vec3_cross(camera->front, (Vec3){0.0f, 1.0f, 0.0f}));
camera->up = vec3_normalize(vec3_cross(camera->right, camera->front));
}

Mat4 camera_get_view_matrix(Camera* camera) {
// Создаем матрицу вида из положения и направления камеры
Vec3 target = vec3_add(camera->position, camera->front);
return look_at(camera->position, target, camera->up);
}

Для реализации освещения создадим структуру света и шейдер:

c
Скопировать код
typedef enum {
LIGHT_DIRECTIONAL,
LIGHT_POINT,
LIGHT_SPOT
} LightType;

typedef struct {
LightType type;
Vec3 position; // для точечных и прожекторных
Vec3 direction; // для направленных и прожекторных
Vec3 ambient;
Vec3 diffuse;
Vec3 specular;

// Параметры затухания для точечных и прожекторных
float constant;
float linear;
float quadratic;

// Для прожекторных
float cutOff;
float outerCutOff;
} Light;

Шейдер для освещения (фрагментный шейдер):

glsl
Скопировать код
// fragment_shader.glsl
#version 330 core
out vec4 FragColor;

in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;

uniform vec3 viewPos;
uniform sampler2D diffuseTexture;

struct Light {
int type; // 0 = directional, 1 = point, 2 = spot
vec3 position;
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;

float constant;
float linear;
float quadratic;

float cutOff;
float outerCutOff;
};

uniform Light light;

void main() {
// Текстурирование
vec3 color = texture(diffuseTexture, TexCoords).rgb;

// Ambient
vec3 ambient = light.ambient * color;

// Diffuse
vec3 norm = normalize(Normal);
vec3 lightDir;

if(light.type == 0) // directional
lightDir = normalize(-light.direction);
else
lightDir = normalize(light.position – FragPos);

float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * color;

// Specular
vec3 viewDir = normalize(viewPos – FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = light.specular * spec;

// Attenuation for point and spot lights
float attenuation = 1.0;
if(light.type != 0) { // not directional
float distance = length(light.position – FragPos);
attenuation = 1.0 / (light.constant + light.linear * distance + 
light.quadratic * (distance * distance));
}

// Spotlight intensity
float intensity = 1.0;
if(light.type == 2) { // spotlight
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff – light.outerCutOff;
intensity = clamp((theta – light.outerCutOff) / epsilon, 0.0, 1.0);
}

// Combine results
diffuse *= attenuation * intensity;
specular *= attenuation * intensity;

vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}

Для работы с текстурами создадим соответствующие функции:

c
Скопировать код
typedef struct {
unsigned int id;
int width, height, channels;
} Texture;

Texture* texture_load(const char* path) {
Texture* texture = malloc(sizeof(Texture));

// Загружаем изображение
unsigned char* data = stbi_load(path, 
&texture->width, 
&texture->height, 
&texture->channels, 
0);

if (!data) {
fprintf(stderr, "Failed to load texture: %s\n", path);
free(texture);
return NULL;
}

// Создаем OpenGL текстуру
glGenTextures(1, &texture->id);
glBindTexture(GL_TEXTURE_2D, texture->id);

// Настраиваем параметры
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

// Загружаем данные и генерируем мипмапы
GLenum format = GL_RGB;
if (texture->channels == 1)
format = GL_RED;
else if (texture->channels == 3)
format = GL_RGB;
else if (texture->channels == 4)
format = GL_RGBA;

glTexImage2D(GL_TEXTURE_2D, 0, format, 
texture->width, texture->height, 
0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

// Освобождаем данные изображения
stbi_image_free(data);

return texture;
}

Оптимизация производительности 3D движка в проектах

Оптимизация — критически важный аспект создания 3D движка. Даже простой движок может быть чрезвычайно требовательным к ресурсам, если не оптимизирован должным образом. 🚀

Ключевые стратегии оптимизации:

  • Отсечение по видимости — рендеринг только тех объектов, которые видны на экране
  • Уровни детализации (LOD) — уменьшение детализации удаленных объектов
  • Пакетная обработка — группировка аналогичных объектов для уменьшения вызовов отрисовки
  • Оптимизация шейдеров — упрощение шейдеров для повышения производительности
  • Управление памятью — эффективная загрузка и выгрузка ресурсов

Реализуем функцию отсечения по видимости (Frustum Culling):

c
Скопировать код
typedef struct {
Vec4 planes[6]; // left, right, bottom, top, near, far
} Frustum;

// Извлечение отсекающих плоскостей из матрицы проекции и вида
void frustum_extract(Frustum* frustum, Mat4 view_proj) {
// Левая плоскость
frustum->planes[0].x = view_proj.m[0][3] + view_proj.m[0][0];
frustum->planes[0].y = view_proj.m[1][3] + view_proj.m[1][0];
frustum->planes[0].z = view_proj.m[2][3] + view_proj.m[2][0];
frustum->planes[0].w = view_proj.m[3][3] + view_proj.m[3][0];

// Правая плоскость
frustum->planes[1].x = view_proj.m[0][3] – view_proj.m[0][0];
frustum->planes[1].y = view_proj.m[1][3] – view_proj.m[1][0];
frustum->planes[1].z = view_proj.m[2][3] – view_proj.m[2][0];
frustum->planes[1].w = view_proj.m[3][3] – view_proj.m[3][0];

// Аналогично для других плоскостей...

// Нормализация плоскостей
for(int i = 0; i < 6; i++) {
float length = sqrt(frustum->planes[i].x * frustum->planes[i].x +
frustum->planes[i].y * frustum->planes[i].y +
frustum->planes[i].z * frustum->planes[i].z);

frustum->planes[i].x /= length;
frustum->planes[i].y /= length;
frustum->planes[i].z /= length;
frustum->planes[i].w /= length;
}
}

// Проверка, видна ли сфера в отсекающих плоскостях
bool frustum_sphere_visible(Frustum* frustum, Vec3 center, float radius) {
for(int i = 0; i < 6; i++) {
// Расстояние от центра сферы до плоскости
float distance = frustum->planes[i].x * center.x +
frustum->planes[i].y * center.y +
frustum->planes[i].z * center.z +
frustum->planes[i].w;

// Если сфера полностью за плоскостью, она невидима
if(distance < -radius)
return false;
}

// Сфера видима или пересекается с отсекающими плоскостями
return true;
}

Реализация простой системы управления уровнями детализации:

c
Скопировать код
typedef struct {
Mesh* high_detail; // Для близких расстояний
Mesh* medium_detail; // Для средних расстояний
Mesh* low_detail; // Для дальних расстояний
float medium_distance;
float low_distance;
} LODModel;

// Выбор подходящего меша в зависимости от расстояния до камеры
Mesh* lod_select_mesh(LODModel* model, Vec3 position, Camera* camera) {
float distance = vec3_distance(position, camera->position);

if(distance < model->medium_distance)
return model->high_detail;
else if(distance < model->low_distance)
return model->medium_detail;
else
return model->low_detail;
}

Техника оптимизации Влияние на производительность Сложность реализации Применимость
Frustum Culling Высокое Средняя Для всех сцен с множеством объектов
Level of Detail (LOD) Высокое Средняя Для сложных моделей и открытых пространств
Occlusion Culling Высокое Высокая Для сцен с множеством перекрывающихся объектов
Instancing Среднее Низкая Для сцен с повторяющимися объектами
Texture Atlasing Среднее Низкая Для оптимизации использования памяти GPU
Shader Optimization Среднее-Высокое Высокая Для всех проектов

Для оптимизации рендеринга похожих объектов используйте инстансинг:

c
Скопировать код
// Подготовка данных для инстансинга
void prepare_instanced_rendering(Mesh* mesh, int instance_count, Mat4* model_matrices) {
// Создаем буфер для матриц моделей
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, instance_count * sizeof(Mat4), 
&model_matrices[0], GL_STATIC_DRAW);

// Настраиваем атрибуты инстансинга для матриц модели
glBindVertexArray(mesh->VAO);

// mat4 = 4 vec4, поэтому занимаем 4 атрибута
for(int i = 0; i < 4; i++) {
glEnableVertexAttribArray(3 + i);
glVertexAttribPointer(3 + i, 4, GL_FLOAT, GL_FALSE, 
sizeof(Mat4), (void*)(i * sizeof(Vec4)));
glVertexAttribDivisor(3 + i, 1); // Изменяем для каждого инстанса
}

glBindVertexArray(0);
}

// Рендеринг инстансированных объектов
void render_instanced(Mesh* mesh, Shader* shader, 
Texture* texture, int instance_count) {
glUseProgram(shader->id);

// Привязываем текстуру
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture->id);
glUniform1i(glGetUniformLocation(shader->id, "diffuseTexture"), 0);

// Установка других параметров шейдера...

// Рендеринг инстансов
glBindVertexArray(mesh->VAO);
glDrawElementsInstanced(GL_TRIANGLES, mesh->index_count, 
GL_UNSIGNED_INT, 0, instance_count);
glBindVertexArray(0);
}

Создание 3D движка на C — сложный, но невероятно вознаграждающий опыт. Овладев основами, представленными в этом руководстве, вы заложили фундамент для разработки более сложных систем рендеринга и графических приложений. Помните, что практика и экспериментирование — лучший способ углубить понимание компьютерной графики. Не бойтесь исследовать, модифицировать и расширять базовый движок, добавляя новые возможности и оптимизации.

Читайте также

Проверь как ты усвоил материалы статьи
Пройди тест и узнай насколько ты лучше других читателей
Что такое 3D движок?
1 / 5

Загрузка...