restructure
This commit is contained in:
93
src/main.rs
93
src/main.rs
@@ -1,17 +1,42 @@
|
|||||||
use crate::vulkan::{Vertex, GameData};
|
use crate::vulkan::{Vertex, GameData, Game};
|
||||||
use winit::{Event, WindowEvent, ElementState};
|
use winit::{Event, WindowEvent, ElementState};
|
||||||
use std::time::SystemTime;
|
|
||||||
use std::iter::FromIterator;
|
use std::iter::FromIterator;
|
||||||
use cgmath::{Matrix4, SquareMatrix, Rad, Point3, Vector3, Deg};
|
use cgmath::{Matrix4, Rad, Point3, Vector3, Deg};
|
||||||
|
|
||||||
mod vulkan;
|
mod vulkan;
|
||||||
use vulkan::vs::ty::PushConstants;
|
use vulkan::vs::ty::PushConstants;
|
||||||
|
|
||||||
const PRINT_KEYBOARD_INPUT: bool = false;
|
const PRINT_KEYBOARD_INPUT: bool = false;
|
||||||
|
|
||||||
impl GameData<'_> {
|
struct TestGame {}
|
||||||
/// Returns true if event should be ignored by the vulkan handler
|
|
||||||
fn on_window_event(self: &mut Self, event: &Event) -> bool {
|
impl Game for TestGame {
|
||||||
|
fn update(self: &mut Self, game_data: &mut GameData) {
|
||||||
|
game_data.push_constants.time = game_data.start_time.elapsed().unwrap().as_millis() as f32 / 1000.0;
|
||||||
|
|
||||||
|
let model = Matrix4::from_angle_z(Rad::from(Deg(game_data.push_constants.time * 100.0)));
|
||||||
|
|
||||||
|
let view = Matrix4::look_at(
|
||||||
|
Point3::new(2.0, 2.0, 2.0),
|
||||||
|
Point3::new(0.0, 0.0, 0.0),
|
||||||
|
Vector3::new(0.0, 0.0, 1.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut proj = cgmath::perspective(
|
||||||
|
Rad::from(Deg(45.0)),
|
||||||
|
game_data.aspect_ratio,
|
||||||
|
0.1,
|
||||||
|
10.0
|
||||||
|
);
|
||||||
|
|
||||||
|
proj.y.y *= -1.0;
|
||||||
|
|
||||||
|
game_data.push_constants.model = model.into();
|
||||||
|
game_data.push_constants.view = view.into();
|
||||||
|
game_data.push_constants.projection = proj.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_window_event(self: &mut Self, game_data: &mut GameData, event: &Event) -> bool {
|
||||||
match event {
|
match event {
|
||||||
Event::WindowEvent { event: WindowEvent::KeyboardInput { device_id, input }, .. } => {
|
Event::WindowEvent { event: WindowEvent::KeyboardInput { device_id, input }, .. } => {
|
||||||
if PRINT_KEYBOARD_INPUT {
|
if PRINT_KEYBOARD_INPUT {
|
||||||
@@ -28,71 +53,29 @@ impl GameData<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.state == ElementState::Released && input.modifiers.ctrl && input.scancode == 19 {
|
if input.state == ElementState::Released && input.modifiers.ctrl && input.scancode == 19 {
|
||||||
self.recreate_pipeline = true;
|
game_data.recreate_pipeline = true;
|
||||||
}
|
}
|
||||||
if input.state == ElementState::Released && input.scancode == 1 {
|
if input.state == ElementState::Released && input.scancode == 1 {
|
||||||
self.shutdown = true;
|
game_data.shutdown = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_push_constants(self: &mut Self) {
|
|
||||||
self.push_constants.time = self.start_time.elapsed().unwrap().as_millis() as f32 / 1000.0;
|
|
||||||
|
|
||||||
let model = Matrix4::from_angle_z(Rad::from(Deg(self.push_constants.time * 100.0)));
|
|
||||||
|
|
||||||
let view = Matrix4::look_at(
|
|
||||||
Point3::new(2.0, 2.0, 2.0),
|
|
||||||
Point3::new(0.0, 0.0, 0.0),
|
|
||||||
Vector3::new(0.0, 0.0, 1.0)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut proj = cgmath::perspective(
|
|
||||||
Rad::from(Deg(45.0)),
|
|
||||||
self.aspect_ratio,
|
|
||||||
0.1,
|
|
||||||
10.0
|
|
||||||
);
|
|
||||||
|
|
||||||
proj.y.y *= -1.0;
|
|
||||||
|
|
||||||
self.push_constants.model = model.into();
|
|
||||||
self.push_constants.view = view.into();
|
|
||||||
self.push_constants.projection = proj.into();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// let mut whatever = String::new();
|
let mut game = TestGame {};
|
||||||
// std::io::stdin().read_line(&mut whatever).unwrap();
|
vulkan::init(vec![
|
||||||
|
|
||||||
let mut pc = PushConstants {
|
|
||||||
time: 0.0,
|
|
||||||
_dummy0: [0; 12],
|
|
||||||
model: Matrix4::identity().into(),
|
|
||||||
view: Matrix4::identity().into(),
|
|
||||||
projection: Matrix4::identity().into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = GameData {
|
|
||||||
mesh_vertices: vec![
|
|
||||||
Vertex { position: [0.1, 0.2, 0.2] },
|
Vertex { position: [0.1, 0.2, 0.2] },
|
||||||
Vertex { position: [0.2, 0.4, 0.2] },
|
Vertex { position: [0.2, 0.4, 0.2] },
|
||||||
Vertex { position: [0.2, 0.2, 0.3] }
|
Vertex { position: [0.2, 0.2, 0.3] }
|
||||||
],
|
],
|
||||||
line_vertices: vec![
|
vec![
|
||||||
Vertex { position: [-0.9, 1., 0.] },
|
Vertex { position: [-0.9, 1., 0.] },
|
||||||
Vertex { position: [0.9, 0., 0.] },
|
Vertex { position: [0.9, 0., 0.] },
|
||||||
],
|
],
|
||||||
push_constants: &mut pc,
|
&mut game
|
||||||
start_time: SystemTime::now(),
|
);
|
||||||
recreate_pipeline: false,
|
|
||||||
aspect_ratio: 1.0,
|
|
||||||
shutdown: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
vulkan::init(data);
|
|
||||||
}
|
}
|
||||||
103
src/vulkan.rs
103
src/vulkan.rs
@@ -24,6 +24,8 @@ use std::time::SystemTime;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::ffi::{CStr};
|
use std::ffi::{CStr};
|
||||||
|
|
||||||
|
use cgmath::{Matrix4, SquareMatrix};
|
||||||
|
|
||||||
use shade_runner;
|
use shade_runner;
|
||||||
use shade_runner::{CompiledShaders, Entry};
|
use shade_runner::{CompiledShaders, Entry};
|
||||||
|
|
||||||
@@ -46,17 +48,40 @@ pub struct Vertex {
|
|||||||
}
|
}
|
||||||
vulkano::impl_vertex!(Vertex, position);
|
vulkano::impl_vertex!(Vertex, position);
|
||||||
|
|
||||||
pub struct GameData<'a> {
|
pub trait Game {
|
||||||
|
fn update(self: &mut Self, game_data: &mut GameData);
|
||||||
|
|
||||||
|
/// Returns true if event should be ignored by the vulkan handler
|
||||||
|
fn on_window_event(self: &mut Self, game_data: &mut GameData, event: &Event) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GameData {
|
||||||
pub start_time: SystemTime,
|
pub start_time: SystemTime,
|
||||||
pub mesh_vertices: Vec<Vertex>,
|
pub mesh_vertices: Vec<Vertex>,
|
||||||
pub line_vertices: Vec<Vertex>,
|
pub line_vertices: Vec<Vertex>,
|
||||||
pub push_constants: &'a mut PushConstants,
|
pub push_constants: PushConstants,
|
||||||
pub recreate_pipeline: bool,
|
pub recreate_pipeline: bool,
|
||||||
pub aspect_ratio: f32,
|
pub aspect_ratio: f32,
|
||||||
pub shutdown: bool,
|
pub shutdown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(mut game: GameData) {
|
pub fn init(mesh_vertices: Vec<Vertex>, line_vertices: Vec<Vertex>, game: &mut dyn Game) {
|
||||||
|
let mut data = GameData {
|
||||||
|
push_constants: PushConstants {
|
||||||
|
time: 0.0,
|
||||||
|
_dummy0: [0; 12],
|
||||||
|
model: Matrix4::identity().into(),
|
||||||
|
view: Matrix4::identity().into(),
|
||||||
|
projection: Matrix4::identity().into(),
|
||||||
|
},
|
||||||
|
start_time: SystemTime::now(),
|
||||||
|
recreate_pipeline: false,
|
||||||
|
aspect_ratio: 1.0,
|
||||||
|
shutdown: false,
|
||||||
|
mesh_vertices,
|
||||||
|
line_vertices,
|
||||||
|
};
|
||||||
|
|
||||||
if ENABLE_VALIDATION_LAYERS {
|
if ENABLE_VALIDATION_LAYERS {
|
||||||
println!("Enabling validation layers...");
|
println!("Enabling validation layers...");
|
||||||
}
|
}
|
||||||
@@ -122,16 +147,8 @@ pub fn init(mut game: GameData) {
|
|||||||
let surface = WindowBuilder::new().build_vk_surface(&events_loop, instance.clone()).unwrap();
|
let surface = WindowBuilder::new().build_vk_surface(&events_loop, instance.clone()).unwrap();
|
||||||
let window = surface.window();
|
let window = surface.window();
|
||||||
|
|
||||||
// The next step is to choose which GPU queue will execute our draw commands.
|
|
||||||
//
|
|
||||||
// Devices can provide multiple queues to run commands in parallel (for example a draw queue
|
|
||||||
// and a compute queue), similar to CPU threads. This is something you have to have to manage
|
|
||||||
// manually in Vulkan.
|
|
||||||
//
|
|
||||||
// In a real-life application, we would probably use at least a graphics queue and a transfers
|
// In a real-life application, we would probably use at least a graphics queue and a transfers
|
||||||
// queue to handle data transfers in parallel. In this example we only use one queue.
|
// queue to handle data transfers in parallel. In this example we only use one queue.
|
||||||
//
|
|
||||||
// We have to choose which queues to use early on, because we will need this info very soon.
|
|
||||||
let queue_family = physical.queue_families().find(|&q| {
|
let queue_family = physical.queue_families().find(|&q| {
|
||||||
q.supports_graphics() && surface.is_supported(q).unwrap_or(false)
|
q.supports_graphics() && surface.is_supported(q).unwrap_or(false)
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
@@ -164,22 +181,19 @@ pub fn init(mut game: GameData) {
|
|||||||
//
|
//
|
||||||
// Because for both of these cases, the swapchain needs to be the window dimensions, we just use that.
|
// Because for both of these cases, the swapchain needs to be the window dimensions, we just use that.
|
||||||
let initial_dimensions = if let Some(dimensions) = window.get_inner_size() {
|
let initial_dimensions = if let Some(dimensions) = window.get_inner_size() {
|
||||||
// convert to physical pixels
|
|
||||||
let dimensions: (u32, u32) = dimensions.to_physical(window.get_hidpi_factor()).into();
|
let dimensions: (u32, u32) = dimensions.to_physical(window.get_hidpi_factor()).into();
|
||||||
[dimensions.0, dimensions.1]
|
[dimensions.0, dimensions.1]
|
||||||
} else {
|
} else {
|
||||||
// The window no longer exists so exit the application.
|
panic!("Couldn't get window dimensions!");
|
||||||
panic!("idk");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Please take a look at the docs for the meaning of the parameters we didn't mention.
|
|
||||||
Swapchain::new(device.clone(), surface.clone(), caps.min_image_count, format,
|
Swapchain::new(device.clone(), surface.clone(), caps.min_image_count, format,
|
||||||
initial_dimensions, 1, usage, &queue, SurfaceTransform::Identity, alpha,
|
initial_dimensions, 1, usage, &queue, SurfaceTransform::Identity, alpha,
|
||||||
PresentMode::Fifo, true, None).unwrap()
|
PresentMode::Fifo, true, None).unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mesh_vertex_buffer = CpuAccessibleBuffer::from_iter(device.clone(), BufferUsage::all(), game.mesh_vertices.iter().cloned()).unwrap();
|
let mesh_vertex_buffer = CpuAccessibleBuffer::from_iter(device.clone(), BufferUsage::all(), data.mesh_vertices.iter().cloned()).unwrap();
|
||||||
let line_vertex_buffer = CpuAccessibleBuffer::from_iter(device.clone(), BufferUsage::all(), game.line_vertices.iter().cloned()).unwrap();
|
let line_vertex_buffer = CpuAccessibleBuffer::from_iter(device.clone(), BufferUsage::all(), data.line_vertices.iter().cloned()).unwrap();
|
||||||
|
|
||||||
let render_pass = Arc::new(vulkano::single_pass_renderpass!(
|
let render_pass = Arc::new(vulkano::single_pass_renderpass!(
|
||||||
device.clone(),
|
device.clone(),
|
||||||
@@ -214,20 +228,8 @@ pub fn init(mut game: GameData) {
|
|||||||
|
|
||||||
// The render pass we created above only describes the layout of our framebuffers. Before we
|
// The render pass we created above only describes the layout of our framebuffers. Before we
|
||||||
// can draw we also need to create the actual framebuffers.
|
// can draw we also need to create the actual framebuffers.
|
||||||
//
|
let mut framebuffers = window_size_dependent_setup(device.clone(), &images, render_pass.clone(), &mut dynamic_state, &mut data.aspect_ratio);
|
||||||
// Since we need to draw to multiple images, we are going to create a different framebuffer for
|
|
||||||
// each image.
|
|
||||||
let mut framebuffers = window_size_dependent_setup(device.clone(), &images, render_pass.clone(), &mut dynamic_state, &mut game.aspect_ratio);
|
|
||||||
|
|
||||||
// In some situations, the swapchain will become invalid by it This includes for example
|
|
||||||
// when the window is resized (as the images of the swapchain will no longer match the
|
|
||||||
// window's) or, on Android, when the application went to the background and goes back to the
|
|
||||||
// foreground.
|
|
||||||
//
|
|
||||||
// In this situation, acquiring a swapchain image or presenting it will return an error.
|
|
||||||
// Rendering to an image of that swapchain will not produce any error, but may or may not work.
|
|
||||||
// To continue rendering, we need to recreate the swapchain by creating a new swapchain.
|
|
||||||
// Here, we remember that we need to do this for the next loop iteration.
|
|
||||||
let mut recreate_swapchain = false;
|
let mut recreate_swapchain = false;
|
||||||
|
|
||||||
// In the loop below we are going to submit commands to the GPU. Submitting a command produces
|
// In the loop below we are going to submit commands to the GPU. Submitting a command produces
|
||||||
@@ -245,10 +247,7 @@ pub fn init(mut game: GameData) {
|
|||||||
// already processed, and frees the resources that are no longer needed.
|
// already processed, and frees the resources that are no longer needed.
|
||||||
previous_frame_end.cleanup_finished();
|
previous_frame_end.cleanup_finished();
|
||||||
|
|
||||||
// Whenever the window resizes we need to recreate everything dependent on the window size.
|
|
||||||
// In this example that includes the swapchain, the framebuffers and the dynamic state viewport.
|
|
||||||
if recreate_swapchain {
|
if recreate_swapchain {
|
||||||
// Get the new dimensions of the window.
|
|
||||||
let dimensions = if let Some(dimensions) = window.get_inner_size() {
|
let dimensions = if let Some(dimensions) = window.get_inner_size() {
|
||||||
let dimensions: (u32, u32) = dimensions.to_physical(window.get_hidpi_factor()).into();
|
let dimensions: (u32, u32) = dimensions.to_physical(window.get_hidpi_factor()).into();
|
||||||
[dimensions.0, dimensions.1]
|
[dimensions.0, dimensions.1]
|
||||||
@@ -267,19 +266,19 @@ pub fn init(mut game: GameData) {
|
|||||||
swapchain = new_swapchain;
|
swapchain = new_swapchain;
|
||||||
// Because framebuffers contains an Arc on the old swapchain, we need to
|
// Because framebuffers contains an Arc on the old swapchain, we need to
|
||||||
// recreate framebuffers as well.
|
// recreate framebuffers as well.
|
||||||
framebuffers = window_size_dependent_setup(device.clone(), &new_images, render_pass.clone(), &mut dynamic_state, &mut game.aspect_ratio);
|
framebuffers = window_size_dependent_setup(device.clone(), &new_images, render_pass.clone(), &mut dynamic_state, &mut data.aspect_ratio);
|
||||||
|
|
||||||
recreate_swapchain = false;
|
recreate_swapchain = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if game.recreate_pipeline {
|
if data.recreate_pipeline {
|
||||||
if let Some(pipeline_ok) = create_pipeline(device.clone(), sub_pass.clone(), "shaders/triangle.vert", "shaders/triangle.frag", false) {
|
if let Some(pipeline_ok) = create_pipeline(device.clone(), sub_pass.clone(), "shaders/triangle.vert", "shaders/triangle.frag", false) {
|
||||||
pipeline = pipeline_ok;
|
pipeline = pipeline_ok;
|
||||||
println!("Updated pipeline.");
|
println!("Updated pipeline.");
|
||||||
} else {
|
} else {
|
||||||
println!("Failed to update pipeline.");
|
println!("Failed to update pipeline.");
|
||||||
}
|
}
|
||||||
game.recreate_pipeline = false;
|
data.recreate_pipeline = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before we can draw on the output, we have to *acquire* an image from the swapchain. If
|
// Before we can draw on the output, we have to *acquire* an image from the swapchain. If
|
||||||
@@ -298,20 +297,11 @@ pub fn init(mut game: GameData) {
|
|||||||
Err(err) => panic!("{:?}", err)
|
Err(err) => panic!("{:?}", err)
|
||||||
};
|
};
|
||||||
|
|
||||||
game.update_push_constants();
|
game.update(&mut data);
|
||||||
|
|
||||||
// Specify the color to clear the framebuffer with i.e. blue
|
// Specify the color to clear the framebuffer with i.e. blue
|
||||||
let clear_values = vec!([0.0, 0.0, 1.0, 1.0].into(), 1f32.into());
|
let clear_values = vec!([0.0, 0.0, 1.0, 1.0].into(), 1f32.into());
|
||||||
|
|
||||||
// In order to draw, we have to build a *command buffer*. The command buffer object holds
|
|
||||||
// the list of commands that are going to be executed.
|
|
||||||
//
|
|
||||||
// Building a command buffer is an expensive operation (usually a few hundred
|
|
||||||
// microseconds), but it is known to be a hot path in the driver and is expected to be
|
|
||||||
// optimized.
|
|
||||||
//
|
|
||||||
// Note that we have to pass a queue family when we create the command buffer. The command
|
|
||||||
// buffer will only be executable on that given queue family.
|
|
||||||
let command_buffer = AutoCommandBufferBuilder::primary_one_time_submit(device.clone(), queue.family()).unwrap()
|
let command_buffer = AutoCommandBufferBuilder::primary_one_time_submit(device.clone(), queue.family()).unwrap()
|
||||||
// Before we can draw, we have to *enter a render pass*. There are two methods to do
|
// Before we can draw, we have to *enter a render pass*. There are two methods to do
|
||||||
// this: `draw_inline` and `draw_secondary`. The latter is a bit more advanced and is
|
// this: `draw_inline` and `draw_secondary`. The latter is a bit more advanced and is
|
||||||
@@ -323,10 +313,7 @@ pub fn init(mut game: GameData) {
|
|||||||
.begin_render_pass(framebuffers[image_num].clone(), false, clear_values).unwrap()
|
.begin_render_pass(framebuffers[image_num].clone(), false, clear_values).unwrap()
|
||||||
|
|
||||||
// We are now inside the first subpass of the render pass. We add a draw command.
|
// We are now inside the first subpass of the render pass. We add a draw command.
|
||||||
//
|
.draw(pipeline.clone(), &dynamic_state, mesh_vertex_buffer.clone(), (), data.push_constants.clone()).unwrap()
|
||||||
// The last two parameters contain the list of resources to pass to the shaders.
|
|
||||||
// Since we used an `EmptyPipeline` object, the objects have to be `()`.
|
|
||||||
.draw(pipeline.clone(), &dynamic_state, mesh_vertex_buffer.clone(), (), game.push_constants.clone()).unwrap()
|
|
||||||
.draw(line_pipeline.clone(), &dynamic_state, line_vertex_buffer.clone(), (), ()).unwrap()
|
.draw(line_pipeline.clone(), &dynamic_state, line_vertex_buffer.clone(), (), ()).unwrap()
|
||||||
|
|
||||||
// We leave the render pass by calling `draw_end`. Note that if we had multiple
|
// We leave the render pass by calling `draw_end`. Note that if we had multiple
|
||||||
@@ -339,13 +326,6 @@ pub fn init(mut game: GameData) {
|
|||||||
|
|
||||||
let future = previous_frame_end.join(acquire_future)
|
let future = previous_frame_end.join(acquire_future)
|
||||||
.then_execute(queue.clone(), command_buffer).unwrap()
|
.then_execute(queue.clone(), command_buffer).unwrap()
|
||||||
|
|
||||||
// The color output is now expected to contain our triangle. But in order to show it on
|
|
||||||
// the screen, we have to *present* the image by calling `present`.
|
|
||||||
//
|
|
||||||
// This function does not actually present the image immediately. Instead it submits a
|
|
||||||
// present command at the end of the queue. This means that it will only be presented once
|
|
||||||
// the GPU has finished executing the command buffer that draws the triangle.
|
|
||||||
.then_swapchain_present(queue.clone(), swapchain.clone(), image_num)
|
.then_swapchain_present(queue.clone(), swapchain.clone(), image_num)
|
||||||
.then_signal_fence_and_flush();
|
.then_signal_fence_and_flush();
|
||||||
|
|
||||||
@@ -370,19 +350,16 @@ pub fn init(mut game: GameData) {
|
|||||||
// Unfortunately the Vulkan API doesn't provide any way to not wait or to detect when a
|
// Unfortunately the Vulkan API doesn't provide any way to not wait or to detect when a
|
||||||
// wait would happen. Blocking may be the desired behavior, but if you don't want to
|
// wait would happen. Blocking may be the desired behavior, but if you don't want to
|
||||||
// block you should spawn a separate thread dedicated to submissions.
|
// block you should spawn a separate thread dedicated to submissions.
|
||||||
|
|
||||||
// Handling the window events in order to close the program when the user wants to close
|
|
||||||
// it.
|
|
||||||
events_loop.poll_events(|ev| {
|
events_loop.poll_events(|ev| {
|
||||||
if !game.on_window_event(&ev) {
|
if !game.on_window_event(&mut data, &ev) {
|
||||||
match ev {
|
match ev {
|
||||||
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => game.shutdown = true,
|
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => data.shutdown = true,
|
||||||
Event::WindowEvent { event: WindowEvent::Resized(_), .. } => recreate_swapchain = true,
|
Event::WindowEvent { event: WindowEvent::Resized(_), .. } => recreate_swapchain = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if game.shutdown { return; }
|
if data.shutdown { return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user