3D Scenes
The Popcorning Guinea Pig engine provides powerful 3D scene capabilities that support both pure 3D worlds and hybrid scenes that combine 3D environments with 2D UI elements. This system is built on top of Raylib's 3D rendering capabilities and integrates seamlessly with the engine's scene management and PGPSCN format.
Skybox
A camera-locked cube of ordinary png faces. Depth-write off, drawn first
inside BeginMode3D. The sun or moon is a texel on a face (put it on +Y
and it cannot appear under the ground). Bottom face is optional.
var sky = new Skybox();
sky.LoadFace(SkyboxFace.PosY, "assets/sky/day-up.png");
// ... +X −X +Z −Z. Skip NegY if the ground covers the downward look.
scene.SetSkybox(sky); // Scene3D draws it
// or, if you override Render:
sky.Draw(camera.Position); // first call inside BeginMode3D
Swap day/night by calling LoadFace / SetFaces again. Recommended face
size is Skybox.RecommendedFaceSize (1024). Ship BC1, never 2048 RGBA8.
See docs/architecture/skybox.md.
Overview
The 3D scene system offers:
- Pure 3D Scenes: Full 3D environments with minimal UI
- Hybrid Scenes: 3D worlds with 2D UI overlays
- 3D UI Elements: UI components positioned in 3D world space
- Multiple Camera Support: Flexible camera configurations
- Quake Map Integration: Support for .bsp map files
- Entity Management: Dynamic object placement and interaction
- Mixed Rendering: Efficient 2D/3D render pipeline
Scene Types
Pure 3D Scenes
Pure 3D scenes focus primarily on 3D content with minimal UI elements:
public class GameplayScene : Scene
{
private Camera3D camera;
private Model worldModel;
public override void Load()
{
// Set up 3D camera
camera = new Camera3D
{
position = new Vector3(0, 10, 10),
target = new Vector3(0, 0, 0),
up = new Vector3(0, 1, 0),
fovy = 45.0f,
projection = CameraProjection.CAMERA_PERSPECTIVE
};
// Load 3D world model
worldModel = Raylib.LoadModel("assets/world.obj");
}
public override void Update(double deltaTime)
{
// Update camera based on input
Raylib.UpdateCamera(ref camera, CameraMode.CAMERA_FIRST_PERSON);
}
public override void Render(int width, int height)
{
Raylib.ClearBackground(Color.RAYWHITE);
Raylib.BeginMode3D(camera);
{
// Draw 3D world
Raylib.DrawModel(worldModel, Vector3.Zero, 1.0f, Color.WHITE);
// Draw 3D grid for reference
Raylib.DrawGrid(20, 1.0f);
}
Raylib.EndMode3D();
// Draw minimal UI
Raylib.DrawFPS(10, 10);
}
}
Hybrid Scenes
Hybrid scenes combine 3D worlds with traditional 2D UI overlays:
public class HybridGameScene : Scene
{
private Camera3D mainCamera;
private Model worldModel;
private List<Button> uiButtons;
private Label healthLabel;
private ProgressBar healthBar;
public override void Load()
{
// Set up 3D camera
mainCamera = new Camera3D
{
position = new Vector3(5, 5, 5),
target = new Vector3(0, 0, 0),
up = new Vector3(0, 1, 0),
fovy = 60.0f,
projection = CameraProjection.CAMERA_PERSPECTIVE
};
// Load 3D assets
worldModel = Raylib.LoadModel("assets/environment/warehouse.obj");
// Create 2D UI overlay
CreateUIOverlay();
}
private void CreateUIOverlay()
{
uiButtons = new List<Button>();
// Health bar in top-left corner
healthBar = new ProgressBar
{
Position = new Vector2(20, 20),
Size = new Vector2(200, 20),
MinValue = 0,
MaxValue = 100,
Value = 100,
FillColor = Color.RED,
BackgroundColor = Color.DARKRED
};
// Settings button in top-right corner
var settingsButton = new Button("Settings", new Vector2(700, 20), new Vector2(80, 30), Color.GRAY);
settingsButton.SetOnClick(() => OpenSettingsMenu());
uiButtons.Add(settingsButton);
}
public override void Update(double deltaTime)
{
// Update 3D camera
Raylib.UpdateCamera(ref mainCamera, CameraMode.CAMERA_FREE);
// Update UI elements
healthBar.Update();
foreach (var button in uiButtons)
button.Update();
}
public override void Render(int width, int height)
{
Raylib.ClearBackground(Color.SKYBLUE);
// Render 3D world
Raylib.BeginMode3D(mainCamera);
{
Raylib.DrawModel(worldModel, Vector3.Zero, 1.0f, Color.WHITE);
}
Raylib.EndMode3D();
// Render 2D UI overlay
healthBar.Draw();
foreach (var button in uiButtons)
button.Draw();
}
}
Camera System
Camera Configuration
The 3D camera system supports multiple camera types and configurations:
public class CameraManager
{
private Camera3D primaryCamera;
private Camera3D uiCamera;
private CameraMode currentMode;
public void InitializeCameras()
{
// Primary 3D camera
primaryCamera = new Camera3D
{
position = new Vector3(0, 10, 10),
target = new Vector3(0, 0, 0),
up = new Vector3(0, 1, 0),
fovy = 75.0f,
projection = CameraProjection.CAMERA_PERSPECTIVE
};
// UI camera for world-space UI elements
uiCamera = new Camera3D
{
position = new Vector3(0, 0, 100),
target = new Vector3(0, 0, 0),
up = new Vector3(0, 1, 0),
fovy = 45.0f,
projection = CameraProjection.CAMERA_ORTHOGRAPHIC
};
currentMode = CameraMode.CAMERA_FREE;
}
public void UpdatePrimaryCamera(double deltaTime)
{
switch (currentMode)
{
case CameraMode.CAMERA_FREE:
Raylib.UpdateCamera(ref primaryCamera, CameraMode.CAMERA_FREE);
break;
case CameraMode.CAMERA_FIRST_PERSON:
Raylib.UpdateCamera(ref primaryCamera, CameraMode.CAMERA_FIRST_PERSON);
break;
case CameraMode.CAMERA_THIRD_PERSON:
Raylib.UpdateCamera(ref primaryCamera, CameraMode.CAMERA_THIRD_PERSON);
break;
}
}
public void SetCameraMode(CameraMode mode)
{
currentMode = mode;
}
public Camera3D GetPrimaryCamera() => primaryCamera;
public Camera3D GetUICamera() => uiCamera;
}
Dynamic Camera Control
Implement dynamic camera behaviors:
public class DynamicCamera
{
private Camera3D camera;
private Vector3 targetPosition;
private Vector3 targetLookAt;
private float smoothSpeed = 2.0f;
public void SetTarget(Vector3 position, Vector3 lookAt)
{
targetPosition = position;
targetLookAt = lookAt;
}
public void Update(double deltaTime)
{
// Smoothly interpolate to target
float t = smoothSpeed * (float)deltaTime;
camera.position = Vector3.Lerp(camera.position, targetPosition, t);
camera.target = Vector3.Lerp(camera.target, targetLookAt, t);
}
public void FollowPlayer(Vector3 playerPosition, Vector3 playerForward)
{
// Position camera behind and above player
var offset = new Vector3(0, 3, -5);
var worldOffset = Vector3.Transform(offset, Matrix.CreateFromYawPitchRoll(0, 0, 0));
SetTarget(playerPosition + worldOffset, playerPosition + playerForward);
}
}
PGPSCN 3D Scene Format
Basic 3D Scene Definition
Define 3D scenes using the PGPSCN format:
{
"version": "1.0",
"sceneType": "3d",
"metadata": {
"name": "Warehouse3D",
"description": "3D warehouse environment"
},
"assets": {
"maps": {
"warehouse": "assets/maps/dm_warehouse.bsp"
},
"textures": {
"wall_texture": "assets/textures/brick_wall.png",
"floor_texture": "assets/textures/concrete_floor.png"
}
},
"world": {
"type": "quake_map",
"mapFile": "warehouse",
"spawn_points": [
{
"id": "player_spawn",
"position": {"x": 256, "y": 256, "z": 32},
"rotation": {"x": 0, "y": 0, "z": 0},
"type": "player"
},
{
"id": "enemy_spawn_1",
"position": {"x": 512, "y": 384, "z": 32},
"rotation": {"x": 0, "y": 180, "z": 0},
"type": "enemy"
}
],
"entities": [
{
"id": "health_pickup",
"type": "HealthPickup",
"position": {"x": 192, "y": 256, "z": 40},
"properties": {
"healthAmount": 25,
"respawnTime": 30.0,
"glowColor": "#00FF00"
}
}
]
},
"cameras": [
{
"id": "main_camera",
"type": "perspective",
"isPrimary": true,
"properties": {
"fov": 75,
"nearPlane": 0.1,
"farPlane": 1000.0,
"position": {"x": 0, "y": 100, "z": 100},
"target": {"x": 0, "y": 0, "z": 0}
}
}
]
}
Hybrid Scene Definition
Define hybrid scenes with both 3D and 2D elements:
{
"version": "1.0",
"sceneType": "hybrid",
"metadata": {
"name": "GameplayHybrid",
"description": "3D gameplay with UI overlay"
},
"world": {
"type": "quake_map",
"mapFile": "dm_warehouse.bsp"
},
"cameras": [
{
"id": "fps_camera",
"type": "perspective",
"isPrimary": true,
"properties": {
"fov": 75,
"nearPlane": 0.1,
"farPlane": 1000.0
},
"bindings": [
{
"property": "position",
"source": "player.position"
},
{
"property": "target",
"source": "player.lookTarget"
}
]
}
],
"renderLayers": [
{
"name": "world_geometry",
"type": "3d",
"depth": 0,
"camera": "fps_camera"
},
{
"name": "world_ui",
"type": "3d_billboard",
"depth": 1,
"camera": "fps_camera"
},
{
"name": "screen_ui",
"type": "2d",
"depth": 2
}
],
"ui_elements": [
{
"id": "health_display",
"type": "Label",
"renderSpace": "screen_2d",
"properties": {
"position": {
"type": "anchor",
"anchor": "TopLeft",
"offset": {"x": 20, "y": 20}
},
"fontSize": 20,
"color": "#00FF00"
},
"bindings": [
{
"property": "text",
"source": "player.health",
"format": "Health: {0}"
}
]
},
{
"id": "floating_damage",
"type": "Label",
"renderSpace": "world_3d",
"properties": {
"text": "-25",
"position": {
"type": "world_position",
"x": 256, "y": 256, "z": 80
},
"fontSize": 16,
"color": "#FF0000",
"billboardMode": "camera_facing"
},
"animations": [
{
"trigger": "onCreate",
"type": "float_up_and_fade",
"duration": 2.0
}
]
}
]
}
3D UI Elements
World-Space UI Components
Create UI elements positioned in 3D world space:
public class WorldSpaceUI
{
private List<IWorldUIElement> worldElements;
private Camera3D worldCamera;
public void Initialize(Camera3D camera)
{
worldCamera = camera;
worldElements = new List<IWorldUIElement>();
}
public void AddFloatingHealthBar(Vector3 worldPosition, float health, float maxHealth)
{
var healthBar = new WorldSpaceHealthBar
{
WorldPosition = worldPosition,
Health = health,
MaxHealth = maxHealth,
Size = new Vector2(100, 10),
BillboardMode = BillboardMode.CameraFacing
};
worldElements.Add(healthBar);
}
public void AddDamageText(Vector3 worldPosition, int damage)
{
var damageText = new WorldSpaceDamageText
{
WorldPosition = worldPosition,
Text = $"-{damage}",
Color = Color.RED,
FontSize = 20,
Duration = 2.0f,
BillboardMode = BillboardMode.CameraFacing
};
worldElements.Add(damageText);
}
public void Update(double deltaTime)
{
for (int i = worldElements.Count - 1; i >= 0; i--)
{
var element = worldElements[i];
element.Update(deltaTime);
if (element.ShouldRemove)
{
worldElements.RemoveAt(i);
}
}
}
public void Render()
{
foreach (var element in worldElements)
{
element.Render(worldCamera);
}
}
}
public interface IWorldUIElement
{
Vector3 WorldPosition { get; set; }
BillboardMode BillboardMode { get; set; }
bool ShouldRemove { get; }
void Update(double deltaTime);
void Render(Camera3D camera);
}
public class WorldSpaceHealthBar : IWorldUIElement
{
public Vector3 WorldPosition { get; set; }
public BillboardMode BillboardMode { get; set; }
public Vector2 Size { get; set; }
public float Health { get; set; }
public float MaxHealth { get; set; }
public bool ShouldRemove => false;
public void Update(double deltaTime)
{
// Update health bar logic
}
public void Render(Camera3D camera)
{
// Convert world position to screen coordinates
var screenPos = Raylib.GetWorldToScreen(WorldPosition, camera);
// Draw background
var bgRect = new Rectangle(screenPos.X - Size.X/2, screenPos.Y - Size.Y/2, Size.X, Size.Y);
Raylib.DrawRectangleRec(bgRect, Color.DARKRED);
// Draw health fill
var healthPercent = MaxHealth > 0 ? Health / MaxHealth : 0;
var fillWidth = Size.X * healthPercent;
var fillRect = new Rectangle(screenPos.X - Size.X/2, screenPos.Y - Size.Y/2, fillWidth, Size.Y);
Raylib.DrawRectangleRec(fillRect, Color.RED);
// Draw border
Raylib.DrawRectangleLinesEx(bgRect, 1, Color.WHITE);
}
}
Billboard UI Elements
UI elements that always face the camera:
public enum BillboardMode
{
None,
CameraFacing,
CameraFacingY, // Only rotate around Y axis
ScreenAligned
}
public class BillboardRenderer
{
public static void RenderBillboard(Vector3 worldPosition, Vector2 size, BillboardMode mode, Camera3D camera, Action renderContent)
{
Matrix transform = Matrix.Identity;
switch (mode)
{
case BillboardMode.CameraFacing:
transform = CreateCameraFacingMatrix(worldPosition, camera);
break;
case BillboardMode.CameraFacingY:
transform = CreateCameraFacingYMatrix(worldPosition, camera);
break;
case BillboardMode.ScreenAligned:
transform = CreateScreenAlignedMatrix(worldPosition, camera);
break;
}
Raylib.rlPushMatrix();
Raylib.rlMultMatrixf(MatrixToFloatArray(transform));
// Render the billboard content
renderContent();
Raylib.rlPopMatrix();
}
private static Matrix CreateCameraFacingMatrix(Vector3 position, Camera3D camera)
{
var forward = Vector3.Normalize(camera.position - position);
var right = Vector3.Normalize(Vector3.Cross(camera.up, forward));
var up = Vector3.Cross(forward, right);
return new Matrix(
right.X, right.Y, right.Z, 0,
up.X, up.Y, up.Z, 0,
forward.X, forward.Y, forward.Z, 0,
position.X, position.Y, position.Z, 1
);
}
private static Matrix CreateCameraFacingYMatrix(Vector3 position, Camera3D camera)
{
var cameraDirection = Vector3.Normalize(camera.position - position);
cameraDirection.Y = 0; // Lock Y axis
cameraDirection = Vector3.Normalize(cameraDirection);
var right = Vector3.Cross(Vector3.UnitY, cameraDirection);
var up = Vector3.UnitY;
return new Matrix(
right.X, right.Y, right.Z, 0,
up.X, up.Y, up.Z, 0,
cameraDirection.X, cameraDirection.Y, cameraDirection.Z, 0,
position.X, position.Y, position.Z, 1
);
}
}
Entity Management
Entity Placement System
Manage dynamic entities in 3D space:
public class EntityManager
{
private Dictionary<string, Entity3D> entities;
private List<SpawnPoint> spawnPoints;
public EntityManager()
{
entities = new Dictionary<string, Entity3D>();
spawnPoints = new List<SpawnPoint>();
}
public void LoadFromPGPSCN(SceneData sceneData)
{
// Load spawn points
if (sceneData.World?.SpawnPoints != null)
{
foreach (var spawnDef in sceneData.World.SpawnPoints)
{
var spawnPoint = new SpawnPoint
{
Id = spawnDef.Id,
Position = new Vector3(spawnDef.Position.X, spawnDef.Position.Y, spawnDef.Position.Z),
Rotation = new Vector3(spawnDef.Rotation.X, spawnDef.Rotation.Y, spawnDef.Rotation.Z),
Type = spawnDef.Type
};
spawnPoints.Add(spawnPoint);
}
}
// Load entities
if (sceneData.World?.Entities != null)
{
foreach (var entityDef in sceneData.World.Entities)
{
var entity = CreateEntity(entityDef);
entities[entityDef.Id] = entity;
}
}
}
private Entity3D CreateEntity(EntityDefinition def)
{
switch (def.Type)
{
case "HealthPickup":
return new HealthPickup
{
Position = new Vector3(def.Position.X, def.Position.Y, def.Position.Z),
HealthAmount = def.Properties.GetValueOrDefault("healthAmount", 25),
RespawnTime = def.Properties.GetValueOrDefault("respawnTime", 30.0f)
};
case "WeaponPickup":
return new WeaponPickup
{
Position = new Vector3(def.Position.X, def.Position.Y, def.Position.Z),
WeaponType = def.Properties.GetValueOrDefault("weaponType", "pistol")
};
default:
return new GenericEntity
{
Position = new Vector3(def.Position.X, def.Position.Y, def.Position.Z)
};
}
}
public Entity3D GetEntity(string id)
{
return entities.GetValueOrDefault(id);
}
public SpawnPoint GetSpawnPoint(string id)
{
return spawnPoints.FirstOrDefault(sp => sp.Id == id);
}
public void Update(double deltaTime)
{
foreach (var entity in entities.Values)
{
entity.Update(deltaTime);
}
}
public void Render(Camera3D camera)
{
foreach (var entity in entities.Values)
{
entity.Render(camera);
}
}
}
public abstract class Entity3D
{
public Vector3 Position { get; set; }
public Vector3 Rotation { get; set; }
public Vector3 Scale { get; set; } = Vector3.One;
public bool IsActive { get; set; } = true;
public abstract void Update(double deltaTime);
public abstract void Render(Camera3D camera);
}
public class HealthPickup : Entity3D
{
public int HealthAmount { get; set; }
public float RespawnTime { get; set; }
public bool IsPickedUp { get; private set; }
private float respawnTimer;
private Model model;
public override void Update(double deltaTime)
{
if (IsPickedUp)
{
respawnTimer -= (float)deltaTime;
if (respawnTimer <= 0)
{
IsPickedUp = false;
}
}
// Rotate the pickup for visual effect
Rotation = new Vector3(Rotation.X, Rotation.Y + (float)deltaTime * 90f, Rotation.Z);
}
public override void Render(Camera3D camera)
{
if (!IsPickedUp && IsActive)
{
Raylib.DrawModel(model, Position, 1.0f, Color.GREEN);
}
}
public void OnPickedUp()
{
IsPickedUp = true;
respawnTimer = RespawnTime;
}
}
Render Pipeline
Mixed 2D/3D Rendering
Implement efficient mixed rendering:
public class MixedRenderer
{
private Camera3D mainCamera;
private List<IRenderLayer> renderLayers;
public MixedRenderer()
{
renderLayers = new List<IRenderLayer>();
}
public void AddRenderLayer(IRenderLayer layer)
{
renderLayers.Add(layer);
// Sort by depth
renderLayers.Sort((a, b) => a.Depth.CompareTo(b.Depth));
}
public void Render(int screenWidth, int screenHeight)
{
Raylib.ClearBackground(Color.SKYBLUE);
foreach (var layer in renderLayers)
{
layer.Render(mainCamera, screenWidth, screenHeight);
}
}
}
public interface IRenderLayer
{
string Name { get; }
int Depth { get; }
void Render(Camera3D camera, int screenWidth, int screenHeight);
}
public class World3DLayer : IRenderLayer
{
public string Name => "world_3d";
public int Depth => 0;
private Model worldModel;
private List<Entity3D> entities;
public void Render(Camera3D camera, int screenWidth, int screenHeight)
{
Raylib.BeginMode3D(camera);
{
// Draw world geometry
if (worldModel.meshCount > 0)
Raylib.DrawModel(worldModel, Vector3.Zero, 1.0f, Color.WHITE);
// Draw entities
foreach (var entity in entities)
entity.Render(camera);
}
Raylib.EndMode3D();
}
}
public class WorldUILayer : IRenderLayer
{
public string Name => "world_ui";
public int Depth => 1;
private List<IWorldUIElement> worldUIElements;
public void Render(Camera3D camera, int screenWidth, int screenHeight)
{
// World UI elements are rendered in screen space but positioned in world space
foreach (var element in worldUIElements)
{
element.Render(camera);
}
}
}
public class ScreenUILayer : IRenderLayer
{
public string Name => "screen_ui";
public int Depth => 2;
private List<IUIElement> screenElements;
public void Render(Camera3D camera, int screenWidth, int screenHeight)
{
// Standard 2D UI rendering
foreach (var element in screenElements)
{
element.Draw();
}
}
}
Best Practices
Performance Optimization
- Frustum Culling: Only render objects visible to the camera
- LOD (Level of Detail): Use lower detail models for distant objects
- Batch Rendering: Group similar objects to reduce draw calls
- Asset Streaming: Load/unload assets based on camera position
public class PerformanceOptimizer
{
public bool IsInCameraFrustum(Vector3 position, float radius, Camera3D camera)
{
// Simple distance-based culling
var distance = Vector3.Distance(camera.position, position);
return distance <= camera.fovy * 10; // Adjust multiplier as needed
}
public Model GetLODModel(float distance, Dictionary<float, Model> lodModels)
{
Model selectedModel = lodModels.Values.First();
foreach (var kvp in lodModels.OrderBy(x => x.Key))
{
if (distance <= kvp.Key)
{
selectedModel = kvp.Value;
break;
}
}
return selectedModel;
}
}
Memory Management
public class Scene3DResourceManager : IDisposable
{
private List<Model> loadedModels;
private List<Texture2D> loadedTextures;
private List<Sound> loadedSounds;
public Model LoadModel(string path)
{
var model = Raylib.LoadModel(path);
loadedModels.Add(model);
return model;
}
public void Dispose()
{
foreach (var model in loadedModels)
Raylib.UnloadModel(model);
foreach (var texture in loadedTextures)
Raylib.UnloadTexture(texture);
foreach (var sound in loadedSounds)
Raylib.UnloadSound(sound);
loadedModels.Clear();
loadedTextures.Clear();
loadedSounds.Clear();
}
}
Debugging and Development
public class Debug3D
{
public static void DrawWireframeMode(bool enabled)
{
if (enabled)
Raylib.rlEnableWireMode();
else
Raylib.rlDisableWireMode();
}
public static void DrawEntityBounds(Entity3D entity, Color color)
{
var bounds = entity.GetBoundingBox();
Raylib.DrawBoundingBox(bounds, color);
}
public static void DrawCameraInfo(Camera3D camera, int x = 10, int y = 10)
{
Raylib.DrawText($"Camera Position: {camera.position}", x, y, 12, Color.WHITE);
Raylib.DrawText($"Camera Target: {camera.target}", x, y + 15, 12, Color.WHITE);
Raylib.DrawText($"Camera FOV: {camera.fovy}", x, y + 30, 12, Color.WHITE);
}
}
Integration Examples
Loading 3D Scene from PGPSCN
public class Scene3DLoader
{
public void LoadScene(string pgpscnPath, Scene3D scene)
{
var sceneData = JsonSerializer.Deserialize<SceneData>(File.ReadAllText(pgpscnPath));
// Load world
if (sceneData.World != null)
{
LoadWorld(sceneData.World, scene);
}
// Setup cameras
if (sceneData.Cameras != null)
{
SetupCameras(sceneData.Cameras, scene);
}
// Create UI elements
if (sceneData.UIElements != null)
{
CreateUIElements(sceneData.UIElements, scene);
}
}
private void LoadWorld(WorldDefinition world, Scene3D scene)
{
if (world.Type == "quake_map")
{
var mapModel = Raylib.LoadModel(world.MapFile);
scene.SetWorldModel(mapModel);
}
// Load spawn points
foreach (var spawn in world.SpawnPoints)
{
scene.AddSpawnPoint(spawn.Id, new Vector3(spawn.Position.X, spawn.Position.Y, spawn.Position.Z));
}
}
}
The 3D scene system in Popcorning Guinea Pig provides powerful capabilities for creating immersive 3D experiences while maintaining the simplicity and flexibility that makes the engine accessible to developers of all skill levels.
Further Reading
- Game Architecture - Core engine architecture
- UI Components - 2D UI system integration
- Asset Registry - Asset management for 3D content
- Input System - 3D camera and movement controls