Skip to content

Commit e8f1bf7

Browse files
andreakarashoclaude
andcommitted
Add 3D viewport rendering and fix image draw order
- Implement render-to-texture 3D viewport with orbit camera, entity shapes, gizmos, and grid in the game editor - Add Sizing property to ButtonStyle for flexible button layouts - Move image render commands before scissor/children so skin backgrounds draw behind content Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 808256b commit e8f1bf7

4 files changed

Lines changed: 271 additions & 65 deletions

File tree

src/Clay.GameEditor/Program.cs

Lines changed: 220 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using ZeroElectric.Vinculum;
44
using Color = Clay.Color;
55
using Vector2 = System.Numerics.Vector2;
6+
using RayColor = ZeroElectric.Vinculum.Color;
67

78
// Window configuration
89
const int InitialWidth = 1440;
@@ -193,6 +194,37 @@
193194
bool assetBrowserOpen = true;
194195
bool consoleOpen = true;
195196

197+
// ============ 3D Scene Setup ============
198+
199+
// Render texture for 3D viewport (resized each frame to match viewport panel)
200+
var viewportRT = Raylib.LoadRenderTexture(InitialWidth, InitialHeight);
201+
var viewportData = new ViewportTextureData { RenderTexture = viewportRT };
202+
203+
// Orbit camera state
204+
var cameraYaw = 45f * MathF.PI / 180f;
205+
var cameraPitch = 30f * MathF.PI / 180f;
206+
var cameraDistance = 20f;
207+
var cameraTarget = new System.Numerics.Vector3(0, 1, 0);
208+
bool viewportHovered = false;
209+
bool orbitDragging = false;
210+
bool panDragging = false;
211+
var lastMousePos = new Vector2(0, 0);
212+
213+
// Entity 3D shape types for rendering
214+
string[] entityShapes =
215+
[
216+
"camera", "light", "capsule", "capsule", "capsule",
217+
"plane", "cone", "cone", "cube", "sphere", "none", "none"
218+
];
219+
220+
System.Numerics.Vector3 CameraPosition()
221+
{
222+
float x = cameraTarget.X + cameraDistance * MathF.Cos(cameraPitch) * MathF.Cos(cameraYaw);
223+
float y = cameraTarget.Y + cameraDistance * MathF.Sin(cameraPitch);
224+
float z = cameraTarget.Z + cameraDistance * MathF.Cos(cameraPitch) * MathF.Sin(cameraYaw);
225+
return new System.Numerics.Vector3(x, y, z);
226+
}
227+
196228
// Main loop
197229
while (!Raylib.WindowShouldClose())
198230
{
@@ -213,6 +245,183 @@
213245

214246
ForwardKeyboardInput();
215247

248+
// ===== Camera Controls (when viewport is hovered) =====
249+
var currentMousePos = new Vector2(mousePos.X, mousePos.Y);
250+
var mouseDelta = new Vector2(currentMousePos.X - lastMousePos.X, currentMousePos.Y - lastMousePos.Y);
251+
lastMousePos = currentMousePos;
252+
253+
if (viewportHovered)
254+
{
255+
// Right-click drag to orbit
256+
if (Raylib.IsMouseButtonDown(MouseButton.MOUSE_BUTTON_RIGHT))
257+
{
258+
if (!orbitDragging) orbitDragging = true;
259+
cameraYaw -= mouseDelta.X * 0.005f;
260+
cameraPitch += mouseDelta.Y * 0.005f;
261+
cameraPitch = Math.Clamp(cameraPitch, -1.4f, 1.4f);
262+
}
263+
else orbitDragging = false;
264+
265+
// Middle-click drag to pan
266+
if (Raylib.IsMouseButtonDown(MouseButton.MOUSE_BUTTON_MIDDLE))
267+
{
268+
if (!panDragging) panDragging = true;
269+
float panSpeed = cameraDistance * 0.003f;
270+
var right = new System.Numerics.Vector3(MathF.Sin(cameraYaw), 0, -MathF.Cos(cameraYaw));
271+
cameraTarget.X -= right.X * mouseDelta.X * panSpeed;
272+
cameraTarget.Z -= right.Z * mouseDelta.X * panSpeed;
273+
cameraTarget.Y += mouseDelta.Y * panSpeed;
274+
}
275+
else panDragging = false;
276+
277+
// Scroll to zoom
278+
if (scrollDelta.Y != 0)
279+
{
280+
cameraDistance -= scrollDelta.Y * cameraDistance * 0.1f;
281+
cameraDistance = Math.Clamp(cameraDistance, 2f, 100f);
282+
}
283+
}
284+
else
285+
{
286+
orbitDragging = false;
287+
panDragging = false;
288+
}
289+
290+
// ===== Resize viewport render texture if needed =====
291+
{
292+
int vpW = Math.Max(1, Raylib.GetScreenWidth());
293+
int vpH = Math.Max(1, Raylib.GetScreenHeight());
294+
if (viewportRT.texture.width != vpW || viewportRT.texture.height != vpH)
295+
{
296+
Raylib.UnloadRenderTexture(viewportRT);
297+
viewportRT = Raylib.LoadRenderTexture(vpW, vpH);
298+
viewportData.RenderTexture = viewportRT;
299+
}
300+
}
301+
302+
// ===== Render 3D Scene to Texture =====
303+
{
304+
var camPos = CameraPosition();
305+
var camera = new Camera3D
306+
{
307+
position = camPos,
308+
target = cameraTarget,
309+
up = new System.Numerics.Vector3(0, 1, 0),
310+
fovy = 60f,
311+
Projection = CameraProjection.CAMERA_PERSPECTIVE
312+
};
313+
314+
Raylib.BeginTextureMode(viewportRT);
315+
Raylib.ClearBackground(new RayColor { r = 30, g = 32, b = 38, a = 255 });
316+
Raylib.BeginMode3D(camera);
317+
318+
// Draw grid
319+
if (editorShowGrid)
320+
{
321+
Raylib.DrawGrid(40, 1.0f);
322+
}
323+
324+
// Draw entities
325+
for (int i = 0; i < sceneEntities.Length; i++)
326+
{
327+
if (!entityActive[i]) continue;
328+
var pos = new System.Numerics.Vector3(entityPosX[i], entityPosY[i], entityPosZ[i]);
329+
var scale = new System.Numerics.Vector3(entityScaleX[i], entityScaleY[i], entityScaleZ[i]);
330+
bool isSel = i == selectedEntity;
331+
var baseColor = entityTags[i] switch
332+
{
333+
"Player" => new RayColor { r = 50, g = 150, b = 255, a = 255 },
334+
"Enemy" => new RayColor { r = 220, g = 60, b = 60, a = 255 },
335+
"Environment" => new RayColor { r = 80, g = 180, b = 80, a = 255 },
336+
"FX" => new RayColor { r = 255, g = 160, b = 40, a = 255 },
337+
"Light" => new RayColor { r = 255, g = 240, b = 150, a = 255 },
338+
"MainCamera" => new RayColor { r = 180, g = 180, b = 200, a = 255 },
339+
"UI" => new RayColor { r = 150, g = 100, b = 220, a = 255 },
340+
"Audio" => new RayColor { r = 100, g = 200, b = 200, a = 255 },
341+
_ => new RayColor { r = 160, g = 160, b = 160, a = 255 }
342+
};
343+
344+
switch (entityShapes[i])
345+
{
346+
case "capsule":
347+
Raylib.DrawCapsule(
348+
new System.Numerics.Vector3(pos.X, pos.Y, pos.Z),
349+
new System.Numerics.Vector3(pos.X, pos.Y + scale.Y * 1.5f, pos.Z),
350+
0.4f * scale.X, 8, 4, baseColor);
351+
if (isSel)
352+
Raylib.DrawCapsuleWires(
353+
new System.Numerics.Vector3(pos.X, pos.Y, pos.Z),
354+
new System.Numerics.Vector3(pos.X, pos.Y + scale.Y * 1.5f, pos.Z),
355+
0.42f * scale.X, 8, 4, Raylib.YELLOW);
356+
break;
357+
case "cube":
358+
Raylib.DrawCube(pos, scale.X, scale.Y, scale.Z, baseColor);
359+
if (isSel) Raylib.DrawCubeWires(pos, scale.X + 0.05f, scale.Y + 0.05f, scale.Z + 0.05f, Raylib.YELLOW);
360+
break;
361+
case "sphere":
362+
Raylib.DrawSphere(pos, 0.5f * scale.X, baseColor);
363+
if (isSel) Raylib.DrawSphereWires(pos, 0.52f * scale.X, 12, 12, Raylib.YELLOW);
364+
break;
365+
case "plane":
366+
Raylib.DrawPlane(pos, new Vector2(scale.X, scale.Z), baseColor);
367+
if (isSel) Raylib.DrawCubeWires(pos, scale.X, 0.02f, scale.Z, Raylib.YELLOW);
368+
break;
369+
case "cone":
370+
Raylib.DrawCylinder(pos, 0, 0.6f * scale.X, scale.Y * 2f, 8, baseColor);
371+
if (isSel) Raylib.DrawCylinderWires(pos, 0, 0.62f * scale.X, scale.Y * 2f + 0.04f, 8, Raylib.YELLOW);
372+
break;
373+
case "light":
374+
// Draw a small sphere for light
375+
Raylib.DrawSphere(pos, 0.3f, baseColor);
376+
if (isSel) Raylib.DrawSphereWires(pos, 0.32f, 8, 8, Raylib.YELLOW);
377+
// Draw light direction line
378+
Raylib.DrawLine3D(pos, new System.Numerics.Vector3(pos.X, pos.Y - 3, pos.Z), baseColor);
379+
break;
380+
case "camera":
381+
// Draw camera as a small wireframe box
382+
Raylib.DrawCube(pos, 0.6f, 0.4f, 0.8f, baseColor);
383+
Raylib.DrawCubeWires(pos, 0.6f, 0.4f, 0.8f, isSel ? Raylib.YELLOW : Raylib.DARKGRAY);
384+
// Lens cone
385+
Raylib.DrawCylinder(
386+
new System.Numerics.Vector3(pos.X, pos.Y, pos.Z - 0.5f),
387+
0.15f, 0.3f, 0.3f, 6, baseColor);
388+
break;
389+
}
390+
391+
// Draw gizmo for selected entity
392+
if (isSel && editorShowGizmos)
393+
{
394+
float gizLen = 1.5f;
395+
// X axis - red
396+
Raylib.DrawLine3D(pos, new System.Numerics.Vector3(pos.X + gizLen, pos.Y, pos.Z),
397+
new RayColor { r = 230, g = 50, b = 50, a = 255 });
398+
// Y axis - green
399+
Raylib.DrawLine3D(pos, new System.Numerics.Vector3(pos.X, pos.Y + gizLen, pos.Z),
400+
new RayColor { r = 50, g = 230, b = 50, a = 255 });
401+
// Z axis - blue
402+
Raylib.DrawLine3D(pos, new System.Numerics.Vector3(pos.X, pos.Y, pos.Z + gizLen),
403+
new RayColor { r = 50, g = 100, b = 230, a = 255 });
404+
}
405+
}
406+
407+
Raylib.EndMode3D();
408+
409+
// Draw viewport overlay text
410+
Raylib.DrawText($"FPS: {Raylib.GetFPS()}", 10, 10,
411+
16, new RayColor { r = 200, g = 200, b = 200, a = 180 });
412+
413+
if (isPlaying)
414+
{
415+
Raylib.DrawText("PLAY MODE", viewportRT.texture.width / 2 - 60, 10,
416+
20, new RayColor { r = 60, g = 200, b = 80, a = 220 });
417+
if (isPaused)
418+
Raylib.DrawText("PAUSED", viewportRT.texture.width / 2 - 40, 34,
419+
16, new RayColor { r = 230, g = 180, b = 50, a = 220 });
420+
}
421+
422+
Raylib.EndTextureMode();
423+
}
424+
216425
// ===== Root Layout =====
217426
ClayUI.BeginVertical(gap: 0, style: new LayoutStyle
218427
{
@@ -292,6 +501,7 @@
292501
Raylib.EndDrawing();
293502
}
294503

504+
Raylib.UnloadRenderTexture(viewportRT);
295505
Clay.Clay.Shutdown();
296506
Raylib.CloseWindow();
297507

@@ -604,48 +814,20 @@ void RenderHierarchyContent()
604814

605815
void RenderViewportContent()
606816
{
607-
// Viewport content area (simulated 3D view)
608-
ClayUI.Spacer();
609-
610-
// Center info overlay
611-
ClayUI.BeginHorizontal(alignment: ChildAlignment.Center, style: new LayoutStyle
817+
// Emit a Custom element that fills the viewport — the renderer will blit the 3D texture here
818+
var vpId = Clay.Clay.Id("ViewportTexture");
819+
using (Clay.Clay.Element(new ElementDeclaration
612820
{
613-
Sizing = new Sizing(SizingAxis.Grow(), SizingAxis.Fit())
614-
});
615-
ClayUI.BeginVertical(gap: 8, alignment: ChildAlignment.Center, style: new LayoutStyle
616-
{
617-
Padding = Padding.All(20),
618-
BackgroundColor = Color.Rgba(0, 0, 0, 80),
619-
CornerRadius = CornerRadius.All(8),
620-
ClipContent = true
621-
});
622-
623-
if (isPlaying)
624-
{
625-
ClayUI.Heading("PLAY MODE", new HeadingStyle { TextColor = colSuccess, FontSize = 18 });
626-
ClayUI.Label($"Scene: MainScene | Time: {playTime:F2}s | FPS: {Raylib.GetFPS()}", new LabelStyle { TextColor = colText, FontSize = 13 });
627-
if (isPaused)
628-
ClayUI.Label("PAUSED", new LabelStyle { TextColor = colWarning, FontSize = 14 });
629-
}
630-
else
821+
Id = vpId,
822+
Layout = new LayoutConfig { Sizing = Sizing.Fill() },
823+
Custom = CustomConfig.Create(viewportData)
824+
}))
631825
{
632-
ClayUI.Label("3D Scene Viewport", new LabelStyle { TextColor = colTextDim, FontSize = 14 });
633-
ClayUI.Label($"Selected: {sceneEntities[selectedEntity]} | FPS: {Raylib.GetFPS()}", new LabelStyle { TextColor = colText, FontSize = 13 });
634-
ClayUI.Label($"Tool: {(gizmoMode == 0 ? "Move" : gizmoMode == 1 ? "Rotate" : "Scale")} ({(gizmoLocal ? "Local" : "Global")})", new LabelStyle { TextColor = colTextDim, FontSize = 12 });
826+
// No children — the Custom render handler draws the 3D scene texture
635827
}
636828

637-
ClayUI.EndVertical();
638-
ClayUI.EndHorizontal();
639-
640-
ClayUI.Spacer();
641-
642-
// Bottom-left camera info
643-
ClayUI.BeginHorizontal(style: new LayoutStyle
644-
{
645-
Padding = Padding.Symmetric(8, 4)
646-
});
647-
ClayUI.Label("Persp | Free Camera | FOV: 60", new LabelStyle { TextColor = Color.Rgba(100, 100, 110), FontSize = 11 });
648-
ClayUI.EndHorizontal();
829+
// Track hover state for camera controls
830+
viewportHovered = Clay.Clay.PointerOver(vpId);
649831
}
650832

651833
// ============ Inspector Panel ============
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Clay.GameEditor;
2+
3+
/// <summary>
4+
/// Custom render data that carries an opaque reference to whatever the viewport rendered.
5+
/// The renderer casts this to the appropriate raylib type.
6+
/// </summary>
7+
public class ViewportTextureData
8+
{
9+
public object RenderTexture = null!;
10+
}

src/Clay/ClayUI.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -922,14 +922,21 @@ public static bool Button(string label, ButtonStyle? style = null, ButtonSkin? s
922922

923923
var skinImg = sk.Background.HasImages ? sk.Background.ForState(isPressed, isHovered) : default;
924924

925+
var layout = new LayoutConfig
926+
{
927+
Padding = s.Padding,
928+
ChildAlignment = ChildAlignment.Center
929+
};
930+
if (s.HasSizing)
931+
{
932+
layout.Sizing = s.Sizing;
933+
layout.ClipContent = true;
934+
}
935+
925936
using (Clay.Element(new ElementDeclaration
926937
{
927938
Id = id,
928-
Layout = new LayoutConfig
929-
{
930-
Padding = s.Padding,
931-
ChildAlignment = ChildAlignment.Center
932-
},
939+
Layout = layout,
933940
BackgroundColor = skinImg.HasImage ? Color.Transparent : DisabledColor(bgColor),
934941
CornerRadius = skinImg.HasImage ? CornerRadius.Zero : s.CornerRadius,
935942
Image = skinImg.HasImage ? SkinToImageConfig(skinImg) : default
@@ -6526,6 +6533,9 @@ public ButtonStyle() { }
65266533
private ushort _fontSize = 14;
65276534
public ushort FontSize { get => _fontSize; set { _fontSize = value; _set |= 1u << 7; } }
65286535

6536+
private Sizing _sizing;
6537+
public Sizing Sizing { get => _sizing; set { _sizing = value; _set |= 1u << 8; } }
6538+
65296539
public ButtonStyle MergeOver(ButtonStyle @base)
65306540
{
65316541
var result = @base;
@@ -6537,8 +6547,12 @@ public ButtonStyle MergeOver(ButtonStyle @base)
65376547
if ((_set & (1u << 5)) != 0) result._cornerRadius = _cornerRadius;
65386548
if ((_set & (1u << 6)) != 0) result._fontId = _fontId;
65396549
if ((_set & (1u << 7)) != 0) result._fontSize = _fontSize;
6550+
if ((_set & (1u << 8)) != 0) result._sizing = _sizing;
6551+
result._set = @base._set | _set;
65406552
return result;
65416553
}
6554+
6555+
internal bool HasSizing => (_set & (1u << 8)) != 0;
65426556
}
65436557

65446558
public struct ImageStyle

0 commit comments

Comments
 (0)