Graphics
KeenEyes provides a modular graphics architecture with backend-agnostic abstractions and Silk.NET OpenGL implementation.
Architecture Overview
The graphics system is split across multiple packages:
| Package | Purpose |
|---|---|
KeenEyes.Graphics.Abstractions |
Backend-agnostic interfaces and components |
KeenEyes.Graphics |
Backend-agnostic systems (CameraSystem, RenderSystem) |
KeenEyes.Graphics.Silk |
Silk.NET OpenGL implementation |
KeenEyes.Runtime |
Main loop builder (WorldRunnerBuilder) |
This separation allows:
- Swapping rendering backends without changing game code
- Writing backend-agnostic plugins and systems
- Testing graphics logic without GPU dependencies
Quick Start
using KeenEyes;
using KeenEyes.Graphics;
using KeenEyes.Graphics.Abstractions;
using KeenEyes.Graphics.Silk;
using KeenEyes.Platform.Silk;
using KeenEyes.Runtime;
// Configure the window
var windowConfig = new WindowConfig
{
Title = "My Game",
Width = 1280,
Height = 720,
VSync = true
};
// Configure graphics rendering
var graphicsConfig = new SilkGraphicsConfig
{
ClearColor = new Vector4(0.2f, 0.3f, 0.4f, 1f),
EnableDepthTest = true,
EnableCulling = true
};
// Create world and install plugins
using var world = new World();
world.InstallPlugin(new SilkWindowPlugin(windowConfig)); // Window first
world.InstallPlugin(new SilkGraphicsPlugin(graphicsConfig));
// Add backend-agnostic systems
world.AddSystem<CameraSystem>(SystemPhase.EarlyUpdate);
world.AddSystem<RenderSystem>(SystemPhase.Render);
// Run with the builder pattern (recommended)
world.CreateRunner()
.OnReady(() => CreateScene(world))
.Run(); // Blocks until window closes, auto-calls world.Update()
Main Loop Setup
Recommended: WorldRunnerBuilder
The WorldRunnerBuilder provides a clean, backend-agnostic way to run your game:
world.CreateRunner()
.OnReady(() =>
{
// Called once when graphics are ready
var graphics = world.GetExtension<IGraphicsContext>();
CreateScene(world, graphics);
})
.OnResize((width, height) =>
{
Console.WriteLine($"Window resized to {width}x{height}");
})
.OnClosing(() =>
{
Console.WriteLine("Goodbye!");
})
.Run(); // Blocks until closed
Key features:
- Auto-update:
world.Update(dt)is called automatically each frame - Backend-agnostic: Works with any
ILoopProviderimplementation - Clean syntax: No manual event wiring
Explicit Update Control
For custom update logic, provide an OnUpdate callback:
world.CreateRunner()
.OnReady(() => CreateScene(world))
.OnUpdate((dt) =>
{
// Custom logic before update
HandleInput();
// Manually call world update
world.Update(dt);
// Custom logic after update
UpdateUI();
})
.Run();
Alternative: Direct Event Wiring
For advanced scenarios, you can wire events directly on ILoopProvider:
var loopProvider = world.GetExtension<ILoopProvider>();
loopProvider.OnReady += () => CreateScene(world);
loopProvider.OnUpdate += (dt) => world.Update(dt);
loopProvider.OnResize += (w, h) => Console.WriteLine($"Resized: {w}x{h}");
loopProvider.OnClosing += () => Console.WriteLine("Closing...");
loopProvider.Initialize();
loopProvider.Run(); // Blocks until closed
Configuration
Window Configuration
var windowConfig = new WindowConfig
{
Title = "My Game", // Window title
Width = 1920, // Initial window width
Height = 1080, // Initial window height
VSync = true, // Enable vertical sync
Resizable = true, // Allow window resizing
TargetFramerate = 60.0, // Target FPS (0 = uncapped)
TargetUpdateFrequency = 60.0 // Target fixed update rate
};
Graphics Configuration
var graphicsConfig = new SilkGraphicsConfig
{
ClearColor = new Vector4(0.1f, 0.1f, 0.1f, 1f), // Background color
EnableDepthTest = true, // Enable depth testing
EnableCulling = true // Enable backface culling
};
Components
All graphics components are in KeenEyes.Graphics.Abstractions for backend independence.
Camera
Defines a viewpoint for rendering:
// Perspective camera (3D with depth)
world.Spawn()
.With(new Transform3D(new Vector3(0, 5, 10), Quaternion.Identity, Vector3.One))
.With(Camera.CreatePerspective(
fieldOfView: 60f, // Vertical FOV in degrees
aspectRatio: 16f/9f, // Width / Height
nearPlane: 0.1f, // Near clip distance
farPlane: 1000f)) // Far clip distance
.WithTag<MainCameraTag>()
.Build();
// Orthographic camera (2D-like, no perspective)
world.Spawn()
.With(Transform3D.Identity)
.With(Camera.CreateOrthographic(
size: 5f, // Half-height of view
aspectRatio: 16f/9f,
nearPlane: 0.1f,
farPlane: 100f))
.Build();
Camera properties:
Priority- Higher values render later (on top)Viewport- Normalized screen region (x, y, width, height)ClearColor- Background color for this cameraClearColorBuffer/ClearDepthBuffer- What to clear before rendering
Renderable
Marks an entity for rendering:
var graphics = world.GetExtension<IGraphicsContext>();
var meshId = graphics.CreateCube();
world.Spawn()
.With(Transform3D.Identity)
.With(new Renderable(meshId.Id, layer: 0))
.With(new Material
{
ShaderId = graphics.LitShader.Id,
TextureId = graphics.WhiteTexture.Id,
Color = new Vector4(1, 0, 0, 1) // Red
})
.Build();
Material
Defines surface appearance:
var material = new Material
{
ShaderId = graphics.LitShader.Id,
TextureId = graphics.WhiteTexture.Id,
Color = new Vector4(1, 1, 1, 1), // White
Metallic = 0.8f, // Metallic look
Roughness = 0.2f // Smooth surface
};
Light
Illuminates the scene:
// Directional light (sun)
world.Spawn()
.With(new Transform3D(Vector3.Zero,
Quaternion.CreateFromYawPitchRoll(0.5f, -0.8f, 0), Vector3.One))
.With(Light.Directional(
color: new Vector3(1, 0.95f, 0.8f),
intensity: 1.0f))
.Build();
// Point light (bulb)
world.Spawn()
.With(new Transform3D(new Vector3(5, 3, 0), Quaternion.Identity, Vector3.One))
.With(Light.Point(
color: new Vector3(1, 0.8f, 0.6f),
intensity: 0.5f,
range: 15f))
.Build();
// Spot light (flashlight)
world.Spawn()
.With(new Transform3D(position, rotation, Vector3.One))
.With(Light.Spot(
color: new Vector3(1, 1, 1),
intensity: 2.0f,
range: 15f,
innerAngle: 15f,
outerAngle: 30f))
.Build();
Resource Management
Access resources through IGraphicsContext:
var graphics = world.GetExtension<IGraphicsContext>();
Meshes
// Built-in primitives
var quad = graphics.CreateQuad(width: 1f, height: 1f);
var cube = graphics.CreateCube(size: 1f);
// Delete when done
graphics.DeleteMesh(quad);
Textures
// From raw RGBA data
var texture = graphics.CreateTexture(256, 256, pixelData);
// Built-in white texture
var white = graphics.WhiteTexture;
// Delete when done
graphics.DeleteTexture(texture);
Shaders
// Built-in shaders
var lit = graphics.LitShader; // PBR lighting
var unlit = graphics.UnlitShader; // No lighting
var solid = graphics.SolidShader; // Solid color
// Custom GLSL shader
var custom = graphics.CreateShader(vertexSource, fragmentSource);
graphics.DeleteShader(custom);
Custom Shaders
KeenEyes uses GLSL shaders with the Silk.NET backend.
Shader Structure
Vertex Shader:
#version 330 core
layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec2 aTexCoord;
layout (location = 2) in vec3 aNormal;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
out vec2 vTexCoord;
out vec3 vNormal;
out vec3 vFragPos;
void main()
{
vec4 worldPos = uModel * vec4(aPosition, 1.0);
gl_Position = uProjection * uView * worldPos;
vTexCoord = aTexCoord;
vNormal = mat3(transpose(inverse(uModel))) * aNormal;
vFragPos = worldPos.xyz;
}
Fragment Shader:
#version 330 core
in vec2 vTexCoord;
in vec3 vNormal;
in vec3 vFragPos;
uniform sampler2D uTexture;
uniform vec4 uColor;
out vec4 FragColor;
void main()
{
vec4 texColor = texture(uTexture, vTexCoord);
FragColor = texColor * uColor;
}
Creating Custom Shaders
var graphics = world.GetExtension<IGraphicsContext>();
// Load from strings
string vertexSource = File.ReadAllText("shaders/custom.vert");
string fragmentSource = File.ReadAllText("shaders/custom.frag");
var shader = graphics.CreateShader(vertexSource, fragmentSource);
// Use in material
var material = new Material
{
ShaderId = shader.Id,
TextureId = myTexture.Id,
Color = Vector4.One
};
// Clean up when done
graphics.DeleteShader(shader);
Built-in Shaders
| Shader | Purpose | Key Uniforms |
|---|---|---|
LitShader |
PBR lighting | uModel, uView, uProjection, light uniforms |
UnlitShader |
No lighting (2D, UI) | uModel, uView, uProjection, uTexture, uColor |
SolidShader |
Solid color fill | uModel, uView, uProjection, uColor |
Common Shader Patterns
Tinting:
// Apply color tint to texture
FragColor = texture(uTexture, vTexCoord) * uColor;
Alpha Cutout (for sprites):
vec4 texColor = texture(uTexture, vTexCoord);
if (texColor.a < 0.5)
discard;
FragColor = texColor * uColor;
Simple Gradient:
// Vertical gradient based on UV
float gradient = vTexCoord.y;
FragColor = mix(uColorTop, uColorBottom, gradient);
Systems
The following systems should be added to your world:
// Camera system - updates camera matrices based on window size
world.AddSystem<CameraSystem>(SystemPhase.EarlyUpdate, order: 0);
// Render system - draws all renderables
world.AddSystem<RenderSystem>(SystemPhase.Render, order: 0);
These systems are in KeenEyes.Graphics and work with any backend implementing IGraphicsContext.
2D Rendering Patterns
While KeenEyes graphics primarily targets 3D, 2D games can use orthographic cameras and specialized patterns.
Orthographic Camera Setup
// Create an orthographic camera for 2D
world.Spawn()
.With(new Transform3D(new Vector3(0, 0, 10), Quaternion.Identity, Vector3.One))
.With(Camera.CreateOrthographic(
size: 5f, // Half-height (10 units total vertically)
aspectRatio: 16f/9f,
nearPlane: 0.1f,
farPlane: 100f))
.WithTag<MainCameraTag>()
.Build();
Sprite Rendering with Quads
var graphics = world.GetExtension<IGraphicsContext>();
var quad = graphics.CreateQuad(1f, 1f); // Unit quad
// Create a sprite entity
world.Spawn()
.With(new Transform3D(new Vector3(0, 0, 0), Quaternion.Identity, Vector3.One))
.With(new Renderable(quad.Id, layer: 0))
.With(new Material
{
ShaderId = graphics.UnlitShader.Id, // No lighting for 2D
TextureId = myTexture.Id,
Color = Vector4.One
})
.Build();
Layer-Based Rendering Order
Use Renderable.Layer for draw order. Lower layers render first (background), higher layers render on top (foreground):
// Background (layer 0)
CreateSprite(backgroundTexture, layer: 0, z: 0);
// Gameplay objects (layer 1)
CreateSprite(playerTexture, layer: 1, z: 0);
// Foreground decorations (layer 2)
CreateSprite(foregroundTexture, layer: 2, z: 0);
// UI (handled by UI system, renders on top)
Pixel-Perfect Rendering
For pixel-art games, calculate orthographic size from your virtual resolution:
int gameHeight = 180; // Virtual resolution height in pixels
float orthoSize = gameHeight / 2f; // Half-height for ortho camera
world.Spawn()
.With(Transform3D.Identity)
.With(Camera.CreateOrthographic(
size: orthoSize,
aspectRatio: (float)windowWidth / windowHeight,
nearPlane: 0.1f,
farPlane: 100f))
.WithTag<MainCameraTag>()
.Build();
Sprite Animation
Animate sprites by updating texture source rectangles or swapping textures:
public class SpriteAnimationSystem : ISystem
{
public void Update(float dt)
{
foreach (var entity in world.Query<SpriteAnimation, Material>())
{
ref var anim = ref world.Get<SpriteAnimation>(entity);
ref var material = ref world.Get<Material>(entity);
anim.Timer += dt;
if (anim.Timer >= anim.FrameDuration)
{
anim.Timer = 0;
anim.CurrentFrame = (anim.CurrentFrame + 1) % anim.FrameCount;
material.TextureId = anim.Frames[anim.CurrentFrame];
}
}
}
}
Complete Example
using System.Numerics;
using KeenEyes;
using KeenEyes.Common;
using KeenEyes.Graphics;
using KeenEyes.Graphics.Abstractions;
using KeenEyes.Graphics.Silk;
using KeenEyes.Platform.Silk;
using KeenEyes.Runtime;
var windowConfig = new WindowConfig
{
Title = "Spinning Cube",
Width = 1280,
Height = 720,
VSync = true
};
var graphicsConfig = new SilkGraphicsConfig
{
ClearColor = new Vector4(0.2f, 0.3f, 0.4f, 1f),
EnableDepthTest = true,
EnableCulling = true
};
using var world = new World();
world.InstallPlugin(new SilkWindowPlugin(windowConfig));
world.InstallPlugin(new SilkGraphicsPlugin(graphicsConfig));
// Add systems
world.AddSystem<CameraSystem>(SystemPhase.EarlyUpdate);
world.AddSystem<RenderSystem>(SystemPhase.Render);
world.AddSystem<SpinSystem>(SystemPhase.Update); // Custom system
// Run with builder pattern
world.CreateRunner()
.OnReady(() =>
{
var graphics = world.GetExtension<IGraphicsContext>();
// Create camera
world.Spawn()
.With(new Transform3D(new Vector3(0, 2, 5), Quaternion.Identity, Vector3.One))
.With(Camera.CreatePerspective(60f, 16f/9f, 0.1f, 100f))
.WithTag<MainCameraTag>()
.Build();
// Create light
world.Spawn()
.With(new Transform3D(Vector3.Zero,
Quaternion.CreateFromYawPitchRoll(0.5f, -0.5f, 0), Vector3.One))
.With(Light.Directional(new Vector3(1, 1, 1), 1f))
.Build();
// Create spinning cube
var cube = graphics.CreateCube();
world.Spawn()
.With(Transform3D.Identity)
.With(new Renderable(cube.Id, 0))
.With(new Material
{
ShaderId = graphics.LitShader.Id,
TextureId = graphics.WhiteTexture.Id,
Color = new Vector4(1, 0.3f, 0.3f, 1f),
Metallic = 0.2f,
Roughness = 0.5f
})
.With(new SpinSpeed { Value = 1f })
.Build();
})
.OnResize((w, h) => Console.WriteLine($"Resized: {w}x{h}"))
.Run();
// Custom component and system
[Component]
public partial struct SpinSpeed { public float Value; }
public class SpinSystem : ISystem
{
private IWorld? world;
public bool Enabled { get; set; } = true;
public void Initialize(IWorld world) => this.world = world;
public void Update(float dt)
{
foreach (var entity in world!.Query<Transform3D, SpinSpeed>())
{
ref var transform = ref world.Get<Transform3D>(entity);
var speed = world.Get<SpinSpeed>(entity);
transform.Rotation *= Quaternion.CreateFromAxisAngle(Vector3.UnitY, speed.Value * dt);
}
}
public void Dispose() { }
}
Lifecycle Events
The ILoopProvider interface (provided by SilkWindowPlugin) provides these events:
| Event | Timing | Use Case |
|---|---|---|
OnReady |
Once, when ready | Create resources, set up scene |
OnUpdate |
Every frame | Game logic (if not using auto-update) |
OnRender |
Every frame | Additional rendering |
OnResize |
When resized | Update viewports, aspect ratios |
OnClosing |
When closing | Final cleanup |
Plugin Architecture
The graphics system requires the window plugin to be installed first:
SilkWindowPlugin (must be installed first)
├── Creates native window
├── Provides ILoopProvider (main loop)
└── Provides ISilkWindowProvider (shared window access)
SilkGraphicsPlugin (uses shared window)
├── Subscribes to window lifecycle events
├── Creates OpenGL context
└── Provides IGraphicsContext
Required installation order:
// Window plugin first (provides ILoopProvider)
world.InstallPlugin(new SilkWindowPlugin(windowConfig));
// Then graphics and/or input plugins (any order after window)
world.InstallPlugin(new SilkGraphicsPlugin(graphicsConfig));
world.InstallPlugin(new SilkInputPlugin(inputConfig));
See the Input documentation for more details.
Debugging & Troubleshooting
Common Issues
Nothing Renders
- Check camera exists: Ensure an entity with
CameraandMainCameraTagexists - Check clear color: Use a bright clear color to verify camera is working
- Check systems added: Verify
CameraSystemandRenderSystemare registered - Check loop provider: Ensure
world.Update(dt)is being called
// Verify camera exists
var cameras = world.Query<Camera>().ToList();
Console.WriteLine($"Camera count: {cameras.Count}");
Objects Not Visible
- Position: Check
Transform3D.Positionis in front of camera - Scale: Ensure scale is not zero
- Material: Verify
ShaderIdandTextureIdare valid handles - Layer: Check
Renderable.Layerisn't being culled
// Debug renderable entities
foreach (var entity in world.Query<Renderable, Transform3D>())
{
var transform = world.Get<Transform3D>(entity);
var renderable = world.Get<Renderable>(entity);
Console.WriteLine($"Entity at {transform.Position}, mesh: {renderable.MeshId}");
}
Black Objects
- Lighting: Add a
Lightentity or useUnlitShader - Texture: Verify texture loaded successfully (valid handle)
- Color alpha: Check
Material.Color.Wis not zero - Shader: Use
UnlitShaderfor 2D or debug
// Quick fix: Add a directional light
world.Spawn()
.With(new Transform3D(Vector3.Zero,
Quaternion.CreateFromYawPitchRoll(0.5f, -0.5f, 0), Vector3.One))
.With(Light.Directional(new Vector3(1, 1, 1), 1f))
.Build();
Performance Issues
- Batching: Group objects with the same material
- Draw calls: Minimize unique material combinations
- Culling: Enable backface culling for closed meshes
- VSync: Disable for uncapped framerate testing
Debug Visualization
// Print rendering statistics
var cameras = world.Query<Camera>().Count();
var renderables = world.Query<Renderable>().Count();
var lights = world.Query<Light>().Count();
Console.WriteLine($"Cameras: {cameras}");
Console.WriteLine($"Renderables: {renderables}");
Console.WriteLine($"Lights: {lights}");
Window Resize Handling
Update camera aspect ratio when the window resizes:
world.CreateRunner()
.OnResize((width, height) =>
{
float aspectRatio = (float)width / height;
foreach (var entity in world.Query<Camera>())
{
ref var camera = ref world.Get<Camera>(entity);
camera.AspectRatio = aspectRatio;
}
})
.Run();
Checking Resource Validity
var graphics = world.GetExtension<IGraphicsContext>();
// Check if handles are valid (non-zero)
if (meshHandle.Id == 0)
Console.WriteLine("Invalid mesh handle!");
if (textureHandle.Id == 0)
Console.WriteLine("Invalid texture handle!");
// Built-in resources are always valid
var whiteTexture = graphics.WhiteTexture; // Always valid
var litShader = graphics.LitShader; // Always valid
Dependencies
- KeenEyes.Graphics.Abstractions - Backend-agnostic interfaces
- KeenEyes.Graphics - Backend-agnostic systems
- KeenEyes.Graphics.Silk - Silk.NET OpenGL backend
- KeenEyes.Platform.Silk - Shared window provider
- KeenEyes.Runtime - WorldRunnerBuilder
- KeenEyes.Common - Transform3D component
- Silk.NET - OpenGL/Vulkan bindings