💻 Neo 3D Console (C#)
A minimalist 3D engine written in C# that runs entirely inside the system console. This project was built from scratch without any external graphical libraries or modern graphics APIs (like OpenGL or DirectX). Everything—from custom vector mathematics to ray casting/tracing and the TCP network stack—is computed entirely on the CPU.
This project was designed for educational and demonstration purposes for a YouTube video.
🚀 Key Features
- CPU Raycasting / Raytracing:
- Renders polygonal meshes (
.objformat) and geometric spheres. - Intersection optimization using Bounding Sphere checks before performing detailed triangle intersection calculations.
- Renders polygonal meshes (
- Dynamic Lighting and Shadows:
- Light intensity attenuation based on distance.
- Lambertian diffuse shading using surface normals.
- Real-time shadow rendering via Shadow Rays cast from intersection points back to light sources.
- Multi-threaded Rendering:
- Leverages
Parallel.Forto distribute pixel rendering calculations across all available CPU cores.
- Leverages
- Optimized Console Output:
- Map light intensity to a character gradient string (
" .:!/r(l1Z4H9W8$@") to simulate shading. - Frame buffering with grouped-color output to minimize slow, native OS terminal print API calls.
- Map light intensity to a character gradient string (
- Custom 3D Mathematics:
- Custom implementations of
Vector3,Vector2,Ray, and rotation matrices (Euler rotations around X, Y, and Z axes). - Manual implementation of the Möller–Trumbore ray-triangle intersection algorithm.
- Custom implementations of
- Low-level Window Input:
- Asynchronous, non-blocking keyboard polling using the Win32 API (
GetAsyncKeyState), ensuring key presses are detected only when the console window is active.
- Asynchronous, non-blocking keyboard polling using the Win32 API (
- Custom Network Multiplayer:
- Client-server TCP network manager built from scratch.
- Custom binary packet serialization, routing via stable hash-based type IDs, and a decoupled event subscription model.
- Included multiplayer demo featuring real-time player position synchronization and a text-based chat lobby.
🏗️ Architecture Overview
The engine is decoupled into clear logical layers:
├── AbstractClass # Base classes for GameObjects, Scenes, Screens, and Lights
├── Implementation # Concrete cameras, rendering managers, and screen renderers
├── Interfaces # Contracts for loose coupling (ICamera, IScreen, etc.)
├── Shape # Geometric primitives (Sphere, Triangle, Object3D)
├── StaticClass # Helper utilities (ObjLoader, GameTime tracking)
├── Structure # Core structs (Vectors, Rays, RenderData payload)
├── UI # Overlay system for rendering text on top of the frame buffer
└── Network # Networking layer (TCP Manager, Packet abstractions, Serializer)
Core Architecture Components
Frame: The heart of the engine loop. It starts the cycle, updates the active scene's logic (Update()), triggers the screen renderer (RenderFrame()), prints FPS/diagnostics, and tracks delta-time.Screen(ConsoleScreenAsync): Manages resolution buffers for brightness and color. ItsRenderFrameimplementation parallelizes the conversion of brightness values into gradient characters and presents the entire frame buffer to the console as grouped color chunks.Scene: A container for world elements. It manages the active camera, light lists, UI elements, and renderable objects (IDisplays). It computes individual pixel states viaGetPixelDataon demand.DisplaysManager: Calculates intersections, searching for the nearest object intersected by rays projected from the viewport.
📐 The Rendering Pipeline
Every frame is rendered using the following steps:
- Ray Generation: The camera translates the 2D UV screen coordinates into a 3D direction vector in world space, adjusting for the camera's position and orientation (Pitch, Yaw, Roll).
- Intersection / Raycast:
The ray is evaluated against all active objects in the scene via the
DisplayManager:- Spheres: Evaluated analytically using the quadratic formula for ray-sphere intersection.
- Polygonal Objects (3D Models): First, a quick intersection check is run against the model's Bounding Sphere. If the ray hits the sphere, a detailed loop checks all individual triangles using the Möller–Trumbore algorithm.
- Shading & Shadow Calculation:
If an intersection is found, the engine calculates the light influence at that point:
- It determines the direction vector toward each light source.
- It calculates the dot product between the surface normal and the light direction (diffuse component).
- It casts a Shadow Ray from the intersection point toward the light. If another object intersects this shadow ray, the point is shaded as in shadow (brightness = 0).
- Buffering & Output: The final light intensity is mapped to a character from the gradient array, buffered, and written out to the console terminal.
⌨️ Cross-Platform Input Architecture
The engine uses a decoupled Strategy Pattern to handle asynchronous, non-blocking input with native OS focus checks. This prevents "background input leakage" (ensuring the game only processes keypresses when your terminal window is actively focused).
[Input (Facade)] ---> [IInputProvider]
|
+-----------------------+-----------------------+
| | |
[User32 (Windows)] [LibX11 (Linux)] [DotNet (Fallback)]
- Windows (
User32InputProvider):- Direct hardware polling via
GetAsyncKeyState. - Active window verification:
GetForegroundWindow() == GetActualConsoleWindow(). It natively resolves parent-owner relationships (GetWindowwithGW_OWNER), supporting both legacyconhost.exeand modern tabbed Windows Terminal on Windows 11.
- Direct hardware polling via
- Linux (
LibX11InputProvider):- Direct hardware polling using
XQueryKeymap(fetching the full 256-bit keyboard state once per frame). - Dynamic, layout-independent mapping: Translates standard
.NETConsoleKeyvalues to X11 KeySyms, resolving them to the user's active layout (QWERTY, AZERTY, Cyrillic, etc.) at startup viaXKeysymToKeycode. - Focus detection via tree-climbing window ID checks, matching the active window against the console's environment
WINDOWIDor parent terminal names.
- Direct hardware polling using
- Universal Fallback (
DotNetInputProvider):- An asynchronous, thread-safe input queue wrapping
.NET's nativeConsole.ReadKey(true). - Implements a timeout-based key-release emulator to simulate smooth real-time KeyUp events and multi-key combinations inside standard command-line pipes.
- Automatically activated in headless servers (SSH), pure Wayland sessions, or sandboxed containers.
- An asynchronous, thread-safe input queue wrapping
🌐 Custom TCP Network Protocol
The networking module is written on raw sockets (TcpListener/TcpClient) with zero third-party dependencies.
- Network Packet Layout:
Packets are serialized into a sequential byte array containing a 12-byte header followed by the payload data:
[ Type ID (4 bytes) ] [ Sender ID (4 bytes) ] [ Payload Length (4 bytes) ] [ Payload Data (N bytes) ] - Registration & Serialization (
PacketManager): Packets inheritINetworkPacketand implement customSerialize/Deserializemethods usingBinaryWriter/BinaryReader. They are mapped to unique integer IDs using a stable hashing function on the class name string. - Event Dispatching:
Scripts subscribe to specific packet types asynchronously using the manager:
PacketManager.Subscribe<T>((packet, senderId) => { ... }).
🛠️ Getting Started
To build and run this project, make sure you have the .NET 8.0 SDK (or newer) installed.
Step 1: File Setup
Organize the codebase according to the directory structure. Make sure you have a valid .obj model file (such as Blender's classic low-poly Suzanne — monkey.obj) located in your output execution directory.
Step 2: Build and Run
Navigate to your project directory and run:
dotnet run --configuration Release
(Running with the Release configuration is highly recommended to ensure maximum parallel performance on your CPU).
Step 3: Multiplayer Setup
When the console starts, choose your network role:
- Press
Sto host as a Server. The terminal will display your local IP and port. Share this with a client. - On another machine (or another terminal instance), press
Cto connect as a Client, input the Server's IP address, and connect.
Default Controls:
W, A, S, D— Move camera (Forward, Left, Backward, Right).Space/Left Shift— Fly Up / Down.- Arrow Keys — Look around (Pitch and Yaw rotations).
Ctrl+W, A, S, D, Space, Shift— Move the active light source.+/-— Increase / Decrease light intensity.T— Open multiplayer chat (Type message ->Enterto send,Escto cancel).
🐧 Running Native High-Performance Input on Linux
To enable hardware-level polling with zero input stuttering and smooth multi-key registration (e.g., strafing with W+A), the engine will automatically try to spin up native polling (LibX11).
Follow these steps to ensure native performance on Unix-based systems:
On Linux (Wayland vs. X11 Bypass)
Modern Linux distributions (like Ubuntu 22.04+ or Fedora) run on Wayland by default. Wayland isolates window sessions for security, blocking global X11 polling. The engine automatically detects Wayland and falls back to safe console-buffering (DotNetInputProvider).
To force the high-performance LibX11 polling on a Wayland system:
- The Terminal Server Trap: GNOME Terminal operates via a background server-daemon. Standard commands to bypass Wayland are ignored by the background server, which stays in Wayland.
- The Solution: Use a standalone terminal emulator (like
xterm) forced to run via XWayland [1.2.2]:sudo apt install xterm WAYLAND_DISPLAY= xterm - Inside the newly opened
xtermwindow, navigate to your project directory and rundotnet run. The engine will successfully bind toLibX11InputProvider.
Note on Containers/Root: If you run the game inside a Docker container or via sudo, you must authorize X11 access on your host machine before launching:
xhost +local:root
📝 Creating a Custom Scene
You can create your own scene by inheriting from the base Scene class. Here is a simple example rendering a single red sphere illuminated by a light source:
using _3dEngine;
using _3dEngine.AbstractClass;
using _3dEngine.Implementation;
using _3dEngine.Interfaces;
using _3dEngine.Shape;
public class MyCustomScene : Scene
{
private Camera _camera;
private Sphere _sphere;
private Light _light;
public MyCustomScene(IDisplaysManagerAsync manager) : base(manager) { }
public override void Start()
{
// 1. Initialize and set up the camera
_camera = new Camera(new Vector3(0, 0, -5), Vector3.Zero);
SetMainCamera(_camera);
// 2. Add a red sphere at the origin
_sphere = new Sphere(Vector3.Zero, Vector3.Zero, r: 1.5f);
_sphere.Color = ConsoleColor.Red;
AddDisplaysObject(_sphere);
// 3. Add a light source pointing at the sphere
_light = new Light(new Vector3(-2, 3, -4), lightPower: 15f);
AddLight(_light);
}
public override void Update()
{
// Add frame update logic here (e.g., rotate objects over time using GameTime.GetDeltaTime())
}
}