はじめに
どうも、Croです。
前回に引き続き、自作した3Dオブジェクトエディタの話をさせていただきます。
前回の記事:3Dオブジェクトエディタのエンジンコアからすべて自作した話(前編)
前回は設計思想などが多く、文字ばかりでプログラム関連が少なかったため、今回、内容について、特に"エンジンコア","Render","カメラ"について紹介させていただこうと思います。
実際のエディタ画面
作品の現在の進捗画像:
機能:
・移動
・回転
・サイズ変更
・外部からのObj読み込み
・選択オブジェクトのハイライト
・各種ショートカット(D,Z,Yなど)
・親子関係の作成、分離
・親子での同時移動
・グリッドサイズ変更による移動距離の増減
そのほかいろいろ
大まかなあらずじ
今回のこのエンジンを製作した大まかな流れは
1.Unityでゲームを作る
2.ProBuilderでの設計に苦労する
3.自作する
こんな感じです。
前回説明した内容は、
1.制作背景
2.企画内容
3.実装方法
の3つです。
今回は、実際に実装したものをコードを多用しつつ説明していきます。
ただし、コードを含めるとおぞましい量になることが予想されたため、かなり厳選して3つにさせていただきました。
実装内容
エンジンコア
本作品の最大の特徴として、エンジンコアからすべて自作であるという点があります。
そのため、GitHubにて公開されているエディタの他に、エンジンコアを単体で公開させていただいています。
DOESUE(自作エンジンコア)このコアの特徴として、Vecotrを二種類使用してグリッドを2つのレイヤーに分けて描画している点があります。
それぞれ、DoubleVector3、IntVector3という名前となっており、名前の通りDoubleとIntで描画するという分離を行っており、見た目上の描画と実数値上の描画をそれぞれ個別で描画しています。
DoubleVector3(移動処理)
public readonly struct DoubleVector3
{
public readonly double X;
public readonly double Y;
public readonly double Z;
public DoubleVector3(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public static DoubleVector3 Zero => new DoubleVector3(0, 0, 0);
public override bool Equals(object? obj)
{
if (obj is not DoubleVector3 other) return false;
return X == other.X && Y == other.Y && Z == other.Z;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y, Z);
}
IntVector3(描画処理)
public class IntVector3
{
public readonly int X;
public readonly int Y;
public readonly int Z;
public IntVector3(int x, int y, int z)
{
X = x;
Y = y;
Z = z;
}
public static IntVector3 Zero => new IntVector3(0, 0, 0);
public static IntVector3 Up => new IntVector3(0, 1, 0);
public static IntVector3 Down => new IntVector3(0, -1, 0);
public static IntVector3 Right => new IntVector3(1, 0, 0);
public static IntVector3 Left => new IntVector3(-1, 0, 0);
public static IntVector3 Forward => new IntVector3(0, 0, 1);
public static IntVector3 Back => new IntVector3(0, 0, -1);
public override string ToString()
=> $"({X}, {Y}, {Z})";
public override bool Equals(object? obj)
{
if (obj is not IntVector3 other) return false;
return X == other.X && Y == other.Y && Z == other.Z;
}
public static bool operator ==(IntVector3 a, IntVector3 b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
return a.X == b.X && a.Y == b.Y && a.Z == b.Z;
}
public static bool operator !=(IntVector3 a, IntVector3 b)
{
return !(a == b);
}
public override int GetHashCode()
=> HashCode.Combine(X, Y, Z);
}
コードはすべてかなり省略して載せさせていただいています。
全文が確認したい場合はGitHubにてご確認ください。
移動は、最終的に細かな調整をする、つまり浮動小数点を含む移動処理まで考える必要があるため、Doubleで処理を行い、描画上はそのまま描画を行うためIntを使用しました。
エンジン本体
お次はエンジン本体の作成に入ります。
本体は、エディタを兼ねておりかなり量が多いので、個人的に特筆すべき部分をピックアップして説明していきます。
Render
まず一つ目はRenderです。本作品のある意味でのコアともいえる部分ですね。
描画に必須であるグリッドから、オブジェクトを立体的に表現するためのライト、
またオブジェクト移動の描画更新や移動した際のグリッドのサイズとそれによるオブジェクトの移動幅など、さまざまな基本動作をまとめて担っています。
実装理由としては、WPF の 3D は Unity のようなシーンビューが存在しないため、無限グリッドを自前で生成し、カメラ位置に合わせて動的に再配置する方式を採用しました。
グリッド描画(処理詰め合わせ)
private double gridSize = 1.0; // グリッド間隔(後で可変にする)
public double GridSize => gridSize;
// グリッド描画用
private Model3DGroup infiniteGrid = new Model3DGroup();
private Model3DGroup gridLines = new Model3DGroup();
public void InitializeGrid(Viewport3D viewport)
{
var brush = CreateFineGridBrush();
fineGridMaterial = new DiffuseMaterial(brush);
fineGridPlane = CreateGridPlane(fineGridMaterial);
// 細線Plane
gridRoot.Children.Add(fineGridPlane);
// 太線グリッド(これを忘れると何も見えない)
gridRoot.Children.Add(gridLines);
}
public void UpdateInfiniteGrid(PerspectiveCamera camera)
{
gridLines.Children.Clear();
double camX = camera.Position.X;
double camZ = camera.Position.Z;
double centerX = Math.Round(camX);
double centerZ = Math.Round(camZ);
var thickBrush = new SolidColorBrush(Color.FromRgb(100, 100, 100));
var thickMat = new DiffuseMaterial(thickBrush);
double worldSpacing = 1.0;
int range = 50;
for (int i = -range; i <= range; i++)
{
double x = centerX + i * worldSpacing;
double z = centerZ + i * worldSpacing;
gridLines.Children.Add(CreateLine(
new Point3D(x, 0, centerZ - range),
new Point3D(x, 0, centerZ + range),
thickMat
));
gridLines.Children.Add(CreateLine(
new Point3D(centerX - range, 0, z),
new Point3D(centerX + range, 0, z),
thickMat
));
}
// 細線は Plane の UV スケールで調整
UpdateFineGridTexture();
}
private Brush CreateFineGridBrush()
{
int size = 256;
DrawingGroup dg = new DrawingGroup();
// 背景:薄いグレー(透明は使わない)
var bg = new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)); // 15% 黒
dg.Children.Add(new GeometryDrawing(
bg,
null,
new RectangleGeometry(new Rect(0, 0, size, size))
));
// 細線の色(濃いグレー)
Brush lineBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80));
// 左端の縦線
dg.Children.Add(new GeometryDrawing(
lineBrush,
new Pen(lineBrush, 2),
new LineGeometry(new Point(0, 0), new Point(0, size))
));
// 上端の横線
dg.Children.Add(new GeometryDrawing(
lineBrush,
new Pen(lineBrush, 2),
new LineGeometry(new Point(0, 0), new Point(size, 0))
));
DrawingBrush brush = new DrawingBrush(dg)
{
TileMode = TileMode.Tile,
ViewportUnits = BrushMappingMode.Absolute, // ここを Absolute に
Viewport = new Rect(0, 0, gridSize, gridSize) // 初期値
};
return brush;
}
private void UpdateFineGridTexture()
{
if (fineGridMaterial?.Brush is DrawingBrush brush)
{
// 1 タイル = gridSize [m]
brush.Viewport = new Rect(0, 0, gridSize, gridSize);
}
}
private GeometryModel3D CreateLine(Point3D p1, Point3D p2, Material mat)
{
double width = 0.02;
Vector3D dir = p2 - p1;
// 上方向との外積で線の太さ方向を作る
Vector3D normal = Vector3D.CrossProduct(dir, new Vector3D(0, 1, 0));
normal.Normalize();
normal *= width;
Point3D v0 = p1 + normal;
Point3D v1 = p1 - normal;
Point3D v2 = p2 + normal;
Point3D v3 = p2 - normal;
var mesh = new MeshGeometry3D();
int index = 0;
mesh.Positions.Add(v0);
mesh.Positions.Add(v1);
mesh.Positions.Add(v2);
mesh.Positions.Add(v3);
mesh.TriangleIndices.Add(index + 0);
mesh.TriangleIndices.Add(index + 1);
mesh.TriangleIndices.Add(index + 2);
mesh.TriangleIndices.Add(index + 2);
mesh.TriangleIndices.Add(index + 3);
mesh.TriangleIndices.Add(index + 0);
return new GeometryModel3D(mesh, mat);
}
これでグリッドを作成し、描画し、さらに更新してきれいに見せることができます。
vector処理の中に、さらにいろいろこねくり回しています。
オブジェクト選択とハイライト
オブジェクトの追加もRenderで行います。
obj取得処理や、別スクリプトで生成されるオブジェクトを取得しidをつけて個体識別をしつつ描画します。
個体識別をすることで、Hierarchy上での親子関係の識別や、削除される同型オブジェクトとの誤認識を防いでいます。
オブジェクト取得と追加・削除
public int? GetObjectIdFromModel(GeometryModel3D model) //オブジェクトID取得
{
foreach (var pair in objectModels)
{
if (pair.Value == model)
{
return pair.Key;
}
}
return null;
}
public void RemoveObject(int id) //オブジェクト削除
{
if (objectModels.TryGetValue(id, out var model))
{
objectRoot.Children.Remove(model);
objectModels.Remove(id);
}
objectTransforms.Remove(id);
// cellObjects から削除(安全版)
List<IntVector3> emptyCells = new();
foreach (var kv in cellObjects)
{
if (kv.Value == null)
continue;
kv.Value.Remove(id);
if (kv.Value.Count == 0)
emptyCells.Add(kv.Key);
}
foreach (var cell in emptyCells)
{
cellObjects.Remove(cell);
}
}
public void AddObject(WorldObject obj)
{
GeometryModel3D model;
if (obj.Model != null)
{
model = obj.Model;
}
else
{
model = new GeometryModel3D
{
Geometry = obj.Mesh,
Material = new DiffuseMaterial(new SolidColorBrush(obj.Color)),
BackMaterial = new DiffuseMaterial(new SolidColorBrush(obj.Color))
};
obj.Model = model;
}
}
ハイライトと解除処理
public void HighlightObject(int id)
{
if (!objectModels.TryGetValue(id, out var model))
return;
if (!objectTransforms.TryGetValue(id, out var group))
return;
// 既存の TransformGroup の ScaleTransform を取得
var scale = group.Children.OfType<ScaleTransform3D>().FirstOrDefault();
if (scale == null) return;
// 元のマテリアル保存
if (!originalMaterials.ContainsKey(id))
originalMaterials[id] = model.Material;
// 元のスケール保存
if (!originalScale.ContainsKey(id))
originalScale[id] = scale.ScaleX;
// スケールだけ変更
scale.ScaleX = 1.1;
scale.ScaleY = 1.1;
scale.ScaleZ = 1.1;
// 色変更
model.Material = new DiffuseMaterial(new SolidColorBrush(Colors.LightBlue));
model.BackMaterial = model.Material;
}
public void UnhighlightObject(int id)
{
if (!objectModels.TryGetValue(id, out var model))
return;
if (!objectTransforms.TryGetValue(id, out var group))
return;
var scale = group.Children.OfType<ScaleTransform3D>().FirstOrDefault();
if (scale == null) return;
// 元のマテリアルに戻す
if (originalMaterials.TryGetValue(id, out var mat))
{
model.Material = mat;
model.BackMaterial = mat;
}
// 元のスケールに戻す
double s = originalScale.ContainsKey(id) ? originalScale[id] : 1.0;
scale.ScaleX = s;
scale.ScaleY = s;
scale.ScaleZ = s;
}
Camera
視覚的に追跡するために必ず必要なカメラ。
ホイールでの接近や、第3ボタンでのベクトル移動ができるようにしています。
ただし、このエディタのコンセプトは移動は直観的にという風にしているため、これでオブジェクトを移動などはできない仕様になっています。
Button2(左クリック)は、移動とオブジェクト生成呼び出しの両方を担っているため、カメラの移動処理の有無によって呼び出しを変えています。
移動処理
bool rightDown = false;
bool moved = false;
Point downPos;
double moveThreshold = 4; // 4px 以上動いたら「移動」と判定
public CameraMove(Viewport3D view)
{
viewport = view;
camera = (PerspectiveCamera)view.Camera;
UpdateCamera();
}
public void FocusOn(Point3D pos)
{
target = pos;
UpdateCamera();
}
public void Attach()
{
var window = Window.GetWindow(viewport);
window.MouseMove += View_MouseMove;
window.MouseDown += View_MouseDown;
window.MouseWheel += View_MouseWheel;
window.MouseUp += View_MouseUp;
}
void View_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.RightButton == MouseButtonState.Pressed)
{
rightDown = true;
moved = false;
downPos = e.GetPosition(viewport);
}
if (e.MiddleButton == MouseButtonState.Pressed)
{
panning = true;
lastMouse = e.GetPosition(viewport);
}
}
if (rotating)
{
~~~
}
if (panning)
{
~~~
}
}
private void View_MouseWheel(object sender, MouseWheelEventArgs e)
{
distance -= e.Delta * 0.05;
}
移動・オブジェクト呼び出し判別
void View_MouseUp(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Right)
{
rightDown = false;
if (!moved )
{
// 右クリック短押し → メニュー表示
viewport.ContextMenu.IsOpen = true;
}
rotating = false;
}
if (e.ChangedButton == MouseButton.Middle)
{
panning = false;
}
}
void View_MouseMove(object sender, MouseEventArgs e)
{
if (rightDown)
{
var pos = e.GetPosition(viewport);
var dx = pos.X - downPos.X;
var dy = pos.Y - downPos.Y;
// 一定距離動いたら「カメラ操作モード」
if (Math.Abs(dx) > moveThreshold || Math.Abs(dy) > moveThreshold)
{
moved = true;
if (!rotating) // ← CameraMove の既存機能を使う
{
rotating = true;
lastMouse = pos;
}
}
}
まとめ
今回はかなり絞って3点のみを紹介しましたが、もしも何か気が向くかすればほかの処理についても書こうと思います。
本作品によって、C#の処理とC++の処理について多くのことを学べました。
また、オブジェクト移動の処理やカメラ処理、親子関係の作成など、ゲーム分野でも活用できる数多くの処理を経験でき、非常に良かったです。
不定期ではありますが、これからもアップデートはしていこうと考えていますので、処理の不備や、処理の無駄などがあった場合など、ぜひぜひプロのニキネキの皆々様方遠慮なくお伝えください。
