CSG: черновой уровень с дверным проёмом
Собрать геометрию булевыми операциями, включить коллизию и получить отдельные ресурсы меша и формы.
Что решает CSG
CSG объединяет и вычитает объёмы: из нескольких простых фигур получается один результат. Это удобно, чтобы проверить размеры комнаты, проходы и линии обзора до подготовки финальных моделей. CSG не заменяет моделирование, развёртку UV и подготовку ассетов.
В примере белая стена содержит настоящий дверной проём. Луч через середину проходит, луч через боковую часть встречает коллизию. Синие блоки рядом — отдельный GridMap для сравнения способов сборки уровня.
Это реальный кадр текущей локальной сборки 1.0.0, Compatibility/OpenGL, 640×420. Он показывает результат игры, не интерфейс редактора и не производительность большого уровня.
Запустить полный пример
Нужны C#-проект Reckfell.NET.Sdk 1.0.0 с net10.0 и уровень .rscene с корнем Entity3D. Сохраните оба файла ниже, выполните dotnet build, прикрепите BlockoutLab3D.cs к корню и запустите уровень. Камера и все ресурсы создаются кодом; внешние модели, текстуры и архивы не нужны.
BlockoutLibrary.cs нужен и для материалов CSG, и для библиотеки синих блоков. BlockoutLab3D.cs строит обе композиции. В примере нет управления персонажем; коллизию проёма отдельно проверяли физическими лучами в движке.
BlockoutLibrary.cs
using Reckfell;
public static class BlockoutLibrary
{
public const int Floor = 0;
public const int Wall = 1;
public static StandardMaterial3D Material(Color color) => new()
{
AlbedoColor = color,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded
};
public static MeshLibrary Create()
{
var library = new MeshLibrary();
AddBox(library, Floor, "Floor", new Vector3(2, 0.2f, 2),
new Vector3(0, -0.1f, 0), new Color(0.15f, 0.18f, 0.23f));
AddBox(library, Wall, "Wall", new Vector3(2, 3, 0.4f),
new Vector3(0, 1.5f, 0), new Color(0.08f, 0.25f, 1));
return library;
}
private static void AddBox(MeshLibrary library, int id, string name,
Vector3 size, Vector3 offset, Color color)
{
library.CreateItem(id);
library.SetItemName(id, name);
library.SetItemMesh(id, new BoxMesh { Size = size, Material = Material(color) });
var transform = new Transform3D(Basis.Identity, offset);
library.SetItemMeshTransform(id, transform);
library.SetItemShapes(id, new Reckfell.Collections.Array
{
new BoxShape3D { Size = size }, transform
});
}
}BlockoutLab3D.cs
using Reckfell;
public partial class BlockoutLab3D : Entity3D
{
public CsgCombiner3D Solid { get; private set; } = null!;
public CsgBox3D Wall { get; private set; } = null!;
public CsgBox3D DoorCut { get; private set; } = null!;
public GridMap Grid { get; private set; } = null!;
public override void _Ready()
{
Solid = new CsgCombiner3D
{
Position = new Vector3(-4, 1.5f, 0),
UseCollision = true,
CollisionLayer = 1,
CollisionMask = 1
};
AddChild(Solid);
Wall = new CsgBox3D
{
Size = new Vector3(5, 3, 0.6f),
Material = BlockoutLibrary.Material(Colors.White)
};
DoorCut = new CsgBox3D
{
Size = new Vector3(1.6f, 2.2f, 1.2f),
Position = new Vector3(0, -0.4f, 0),
Operation = CsgShape3D.OperationEnum.Subtraction
};
Solid.AddChild(Wall);
Solid.AddChild(DoorCut);
Grid = new GridMap
{
Position = new Vector3(4, 0, 0),
CellSize = new Vector3(2, 2, 2),
CellCenterX = true,
CellCenterY = false,
CellCenterZ = true,
MeshLibrary = BlockoutLibrary.Create(),
CollisionLayer = 1,
CollisionMask = 1
};
AddChild(Grid);
for (int x = -2; x <= 2; x++)
for (int z = -1; z <= 1; z++)
Grid.SetCellItem(new Vector3I(x, 0, z), BlockoutLibrary.Floor);
foreach (int x in new[] { -2, -1, 1, 2 })
Grid.SetCellItem(new Vector3I(x, 0, 1), BlockoutLibrary.Wall);
AddChild(new MeshInstance3D
{
Mesh = new PlaneMesh { Size = new Vector2(24, 16) },
Position = new Vector3(0, -0.21f, 0),
MaterialOverride = BlockoutLibrary.Material(new Color(0.06f, 0.07f, 0.09f))
});
var camera = new Camera3D { Current = true, Position = new Vector3(12, 9, 17), Fov = 55 };
AddChild(camera);
camera.LookAt(new Vector3(0, 1, 1));
}
}Разобрать порядок операций
Solid — корневой CsgCombiner3D. Его первый ребёнок Wall добавляет коробку 5×3×0.6. Следующий DoorCut с Operation=Subtraction вычитает коробку 1.6×2.2×1.2. Вырезающая коробка толще стены и пересекает её насквозь; нижняя грань проёма совпадает с уровнем пола.
| Операция дочернего CSG-объекта | Результат |
|---|---|
| Union | Добавить объём к накопленной геометрии |
| Subtraction | Удалить пересекающуюся часть накопленного объёма |
| Intersection | Оставить только общую часть объёмов |
В этом исполняемом примере проверены объединение исходной стены и вычитание двери. Сложные цепочки Intersection с импортированными мешами не проверялись. Порядок операндов влияет на результат: сначала должна существовать геометрия, из которой вырезают.
Операнды добавлены прямыми детьми CSG-корня. Отдельный CSG-корень рядом не вычитается автоматически из другого. Не вставляйте обычный Entity3D между этими операндами и Solid в ожидании прежней композиции.
Включить физику результата
UseCollision=true задан у Solid. Физическая форма относится к результату корня: DoorCut не становится отдельным невидимым препятствием. CollisionLayer=1 помещает стену на первый слой; запрос или персонаж должен проверять соответствующий бит.
Отрисовка и коллизия обновляются отложенно. После изменения размера или трансформации дождитесь обновления, прежде чем проверять попадание или получать готовый меш. Не перестраивайте сложную CSG-композицию на каждом кадре без профилирования.
В проверке луч через (-4,1) по X/Y не попал в стену, а через (-5.5,1) попал. После UseCollision=false боковой луч тоже перестал попадать, хотя геометрия осталась видимой. Визуальная видимость и физическая коллизия — разные настройки.
Получить статические ресурсы
Вызывайте методы у Solid, после готовности результата:
- BakeStaticMesh() получает ArrayMesh. Проверьте результат на null. Метод не создаёт MeshInstance3D и не сохраняет файл; это текущий ресурс root mesh, а не гарантированная независимая глубокая копия.
- BakeCollisionShape() создаёт отдельный ConcavePolygonShape3D. Он работает и при UseCollision=false, но сам ресурс не добавляет коллизию в мир.
- Для статического варианта назначьте меш MeshInstance3D, а форму — CollisionShape3D внутри StaticBody3D. Сохраните трансформацию корня. Не оставляйте одновременно две одинаковые физические стены.
- При сохранении ресурсов вызывайте ResourceSaver.Save отдельно для меша и формы с путями .rdata и проверяйте Error.Ok. Затем загрузите их и проверьте данные; одна успешная запись не доказывает правильность всего уровня.
Нативная проверка сохранила оба ресурса, загрузила их без кэша и сравнила геометрию. Отдельное StaticBody3D с запечённой формой сохранило стену и проём. Полное сохранение дерева уровня и интерфейс команды запекания редактора этим примером не проверялись.
У дочерних Wall/DoorCut bake-методы не возвращают самостоятельный результат: используйте корень композиции. Вогнутую треугольную форму применяйте к статической геометрии; для подвижных физических тел подбирайте примитивы или выпуклые формы отдельно.
Диагностика
| Симптом | Проверка |
|---|---|
| Проём не появился | Operation, порядок прямых CSG-детей и пересечение выреза со стеной |
| Видимый проход блокирует тело | Коллизия старой стены, готовность обновления, дублирующее физическое тело |
| Персонаж проходит сквозь стену | UseCollision у корня и совместимые физические слои/маски |
| BakeStaticMesh вернул null | Это корень? Готов ли отложенный root mesh? |
| Получен меш, но нет коллизии | Созданы ли StaticBody3D и CollisionShape3D с отдельной формой |
| Текстура выглядит растянутой | Масштабы и UV исходных примитивов; CSG не выполняет полноценную художественную развёртку |