gemini_engine/view3d/mod.rs
1//! Gemini's implementation of 3D rendering. Capable of rendering full 3D meshes as wireframes, solid colours or with lighting
2//!
3//! ## Example
4//! Let's write a simple example program to draw a spinning cube. This example is available in `examples/spinning-cube.rs`
5//! ```no_run
6#![doc = include_str!("../../examples/spinning-cube.rs")]
7//! ```
8//! There is a lot of code here, but here we'll only focus on the parts that are different from the [`gameloop`](crate::gameloop) example:
9//!
10//! ### Initialisation
11//! ```no_run
12//! # use gemini_engine::{core::{ColChar, Vec2D}, view::View};
13//! # use gemini_engine::{view3d::{Viewport, Light, DisplayMode}, mesh3d::{Mesh3D, Transform3D, Vec3D}};
14//! # const FOV: f64 = 95.0;
15//! let mut view = View::new(100, 50, ColChar::EMPTY);
16//!
17//! let mut viewport = Viewport::new(
18//! Transform3D::look_at_lh(Vec3D::new(0.0, -1.5, 4.3), Vec3D::ZERO, Vec3D::Y),
19//! FOV,
20//! view.center(),
21//! );
22//! viewport.objects.push(Mesh3D::default_cube());
23//!
24//! viewport.display_mode = DisplayMode::Illuminated {
25//! lights: vec![
26//! Light::new_ambient(0.3),
27//! Light::new_directional(0.6, Vec3D::new(0.5, 1.0, 1.0)),
28//! ],
29//! };
30//! ```
31//! `main()` begins with the creation of all the necessary objects to render 3D images:
32//! 1. [`View`](crate::view::View) to handle the canvas and printing to the screen
33//! 2. [`Viewport`] to handle drawing 3d objects to the canvas
34//! 3. The actual objects you intend to use in the scene, as [`Mesh3D`]
35//!
36//! In this scenario, we create a [`View`](crate::view::View) of width 100 and height 50 (you may have to zoom out and expand your terminal to fit the whole image), a [`Viewport`] with a camera positioned at (0,-1.5,4.3) pointing at (0,0,0), our desired FOV and origin point (the centre of the view we're printing to) in the middle of the [`View`](crate::view::View). We add a single default cube, which is 2 units tall, wide and long to the `Viewport`s list of objects, and set the `Viewport`'s `display_mode` to [`DisplayMode::Illuminated`] with a simple lighting setup
37//!
38//! ### Gameloop process logic
39//! ```no_run
40//! # use gemini_engine::{view::View, core::{Vec2D, ColChar}};
41//! # use gemini_engine::{view3d::Viewport, mesh3d::Transform3D};
42//! # const FOV: f64 = 5000.0;
43//! # let view = View::new(350, 90, ColChar::BACKGROUND);
44//! # let mut viewport = Viewport::new(
45//! # Transform3D::IDENTITY,
46//! # FOV,
47//! # view.size(),
48//! # );
49//! viewport.objects[0].transform = viewport.objects[0]
50//! .transform
51//! .mul_mat4(&Transform3D::from_rotation_y(-0.05));
52//! ```
53//!
54//! This part of the code is where we would put all our physics, collisions, events etc. code, but in this case the only thing we do is rotate the cube 0.05 radians anticlockwise in the Y axis.
55//!
56//! ### Drawing/Rendering
57//! ```no_run
58//! # use gemini_engine::{core::{Vec2D, ColChar}, view::View};
59//! # use gemini_engine::view3d::{Viewport, DisplayMode};
60//! # use gemini_engine::mesh3d::{Mesh3D, Transform3D};
61//! # const FOV: f64 = 5000.0;
62//! # let mut view = View::new(350, 90, ColChar::BACKGROUND);
63//! # let viewport = Viewport::new(
64//! # Transform3D::IDENTITY,
65//! # FOV,
66//! # view.size(),
67//! # );
68//! view.clear();
69//! view.draw(&viewport);
70//! let _ = view.display_render();
71//! ```
72//!
73//! This part of the code renders and draws all the 3d stuff to the [`View`](crate::view::View) before rendering with `display_render` as usual. [`Viewport`] implements [`CanDraw`], so when it is draw to the `View`, it fully renders our scene based on its stored transform, display mode, objects, etc.
74
75use crate::{
76 core::{CanDraw, Vec2D},
77 mesh3d::{Mesh3D, Transform3D},
78 primitives::{Line, Polygon},
79};
80use glam::DVec2;
81
82mod display_mode;
83mod projected_face;
84
85pub use display_mode::{
86 DisplayMode,
87 lighting::{BRIGHTNESS_CHARS, Light, LightType},
88};
89use projected_face::{ProjectedFace, ProjectedVertex};
90
91/// The `Viewport` handles drawing 3D objects to a 2D [`Canvas`](crate::core::Canvas), and also acts as the scene's camera.
92pub struct Viewport {
93 /// This transform is applied to every vertex in the scene. [`Transform3D::look_at_lh`] works best for this
94 pub camera_transform: Transform3D,
95 /// The Viewport's field of view, in degrees
96 pub fov: f64,
97 /// The centre of the view you intend to draw to. [`View.centre()`](crate::view::View::center) returns exactly what you need for this
98 pub canvas_centre: Vec2D,
99 /// The objects to be drawn on the screen
100 pub objects: Vec<Mesh3D>,
101 /// The style in which the objects should be rendered. Read [`DisplayMode`] for more info
102 pub display_mode: DisplayMode,
103 /// Most terminals don't have perfectly square characters. The value you set here is how much the final image will be stretched in the X axis to account for this. The default value is `2.0` but it will be different in most terminals
104 pub character_width_multiplier: f64,
105 /// Any face with vertices closer to the viewport than this value will be clipped
106 pub clipping_distace: f64,
107}
108
109impl Viewport {
110 /// Create a new `Viewport`
111 #[must_use]
112 pub const fn new(camera_transform: Transform3D, fov: f64, canvas_centre: Vec2D) -> Self {
113 Self {
114 camera_transform,
115 fov,
116 canvas_centre,
117 objects: Vec::new(),
118 display_mode: DisplayMode::Solid,
119 character_width_multiplier: 2.0,
120 clipping_distace: 0.3,
121 }
122 }
123
124 /// Transform the vertices with the object transform, view transform and perspective transform
125 fn get_vertices_on_screen(&self, object: &Mesh3D) -> Vec<ProjectedVertex> {
126 let world_transform = self.camera_transform.mul_mat4(&object.transform);
127 let perspective =
128 Transform3D::perspective_infinite_rh(self.fov.to_radians(), 1.0, self.clipping_distace);
129
130 let centre = DVec2::new(self.canvas_centre.x as f64, self.canvas_centre.y as f64);
131 let size = DVec2::splat(centre.max_element());
132
133 object
134 .vertices
135 .iter()
136 .map(|v| {
137 let v = world_transform.transform_point3(*v); // Object and camera transform
138 let pv = perspective.project_point3(v); // Perspective
139 let pv = DVec2::new(pv.x * self.character_width_multiplier, -pv.y) * size + centre;
140 ProjectedVertex::new(v, Vec2D::new(pv.x as i64, pv.y as i64))
141 })
142 .collect()
143 }
144
145 /// Project the models' faces onto a 2D plane. Returns a collection of `ProjectedFace`s, each storing its projected vertices, normal and z index
146 fn project_faces(&self, sort_faces: bool, backface_culling: bool) -> Vec<ProjectedFace> {
147 let mut screen_faces = vec![];
148
149 for object in &self.objects {
150 let vertices = self.get_vertices_on_screen(object);
151 for face in &object.faces {
152 let face_vertices = face
153 .index_into(&vertices)
154 .expect("Failed to index mesh vertices with face indices");
155
156 for v in &face_vertices {
157 if v.original.z <= self.clipping_distace {
158 continue; // Do not render if behind player
159 }
160 }
161
162 if backface_culling && !projected_face::is_clockwise(&face_vertices) {
163 continue; // Backface culling
164 }
165
166 screen_faces.push(ProjectedFace::new(face_vertices, face.fill_char));
167 }
168 }
169
170 if sort_faces {
171 screen_faces
172 .sort_by_key(|face| (face.original_centre.length() * -1000.0).round() as isize);
173 }
174
175 screen_faces
176 }
177}
178
179impl CanDraw for Viewport {
180 /// Project the `models` and draw them onto a [`Canvas`](crate::core::Canvas)
181 fn draw_to(&self, canvas: &mut impl crate::core::Canvas) {
182 match &self.display_mode {
183 DisplayMode::Wireframe { backface_culling } => {
184 let screen_faces = self.project_faces(false, *backface_culling);
185
186 for face in screen_faces {
187 for fi in 0..face.vertices.len() {
188 Line::new(
189 face.vertices[fi],
190 face.vertices[(fi + 1) % face.vertices.len()],
191 face.fill_char,
192 )
193 .draw_to(canvas);
194 }
195 }
196 }
197 DisplayMode::Solid => {
198 let screen_faces = self.project_faces(true, true);
199
200 for face in screen_faces {
201 Polygon::new(&face.vertices, face.fill_char).draw_to(canvas);
202 }
203 }
204 DisplayMode::Illuminated { lights } => {
205 let screen_faces = self.project_faces(true, true);
206
207 let brightness_chars: Vec<char> = BRIGHTNESS_CHARS.chars().collect();
208 let len_brightness_chars: f64 = brightness_chars.len() as f64;
209
210 for face in screen_faces {
211 let Some(normal) = face.normal else {
212 continue;
213 };
214
215 let intensity: f64 = lights
216 .iter()
217 .map(|light| light.calculate_intensity(face.original_centre, normal))
218 .sum();
219
220 let brightness_char_index = ((intensity * len_brightness_chars).round()
221 as usize)
222 .clamp(0, brightness_chars.len() - 1);
223 let intensity_char = brightness_chars[brightness_char_index];
224
225 Polygon::new(&face.vertices, face.fill_char.with_char(intensity_char))
226 .draw_to(canvas);
227 }
228 }
229 }
230 }
231}