Started On Renderer
This commit is contained in:
parent
375af6829c
commit
4194764d7a
@ -6,6 +6,7 @@
|
||||
|
||||
#include "systems/MACROS.h"
|
||||
#include "systems/AssetManager.h"
|
||||
#include "systems/assets/Texture2D.h"
|
||||
|
||||
namespace OX
|
||||
{
|
||||
@ -32,9 +33,7 @@ namespace OX
|
||||
}
|
||||
|
||||
|
||||
|
||||
for (const auto &layer : m_layers) {
|
||||
|
||||
for (const auto &layer: m_layers) {
|
||||
Logger::LogDebug("Initializing Layer: '%s'", layer->GetName().c_str());
|
||||
layer->Init(*this);
|
||||
}
|
||||
@ -42,6 +41,7 @@ namespace OX
|
||||
|
||||
std::string fullTitle = layerTitle + " - " + m_name;
|
||||
window.SetWindowTitle(fullTitle);
|
||||
renderer.Init(800, 600);
|
||||
|
||||
Logger::LogOk("Core Initialization Complete.");
|
||||
|
||||
@ -49,7 +49,6 @@ namespace OX
|
||||
}
|
||||
|
||||
|
||||
|
||||
void Core::Run()
|
||||
{
|
||||
m_running = true;
|
||||
@ -80,6 +79,18 @@ namespace OX
|
||||
{
|
||||
OX_PROFILE_FUNCTION();
|
||||
|
||||
Camera2D cam(0, 800, 0, 600);
|
||||
renderer.BeginScene(cam);
|
||||
|
||||
if (auto asset = AssetManager::Get("res://Assets/tile_001.png"); asset && asset->GetTypeName() == "texture2D") {
|
||||
const auto tex = std::static_pointer_cast<Texture2D>(asset);
|
||||
renderer.DrawSprite({tex->GetID(), {100, 100}, {64, 64}});
|
||||
}
|
||||
|
||||
|
||||
|
||||
renderer.EndScene();
|
||||
|
||||
|
||||
for (auto &layer: m_layers) {
|
||||
layer->Draw(*this);
|
||||
|
@ -1,9 +1,162 @@
|
||||
//
|
||||
// Created by spenc on 5/21/2025.
|
||||
//
|
||||
|
||||
// File: src/Renderer.cpp
|
||||
#include "Renderer.h"
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
#include <iostream>
|
||||
|
||||
namespace OX {
|
||||
|
||||
} // OX
|
||||
// ——— Camera2D implementation (unchanged) ———
|
||||
Camera2D::Camera2D(float left, float right, float bottom, float top)
|
||||
: _left(left), _right(right), _bottom(bottom), _top(top)
|
||||
{ Recalculate(); }
|
||||
|
||||
void Camera2D::SetPosition(const glm::vec2& pos) { _position = pos; Recalculate(); }
|
||||
void Camera2D::SetZoom(float z) { _zoom = z; Recalculate(); }
|
||||
|
||||
void Camera2D::Recalculate() {
|
||||
float halfW = (_right - _left) * 0.5f / _zoom;
|
||||
float halfH = (_top - _bottom) * 0.5f / _zoom;
|
||||
glm::mat4 proj = glm::ortho(-halfW, halfW, -halfH, halfH, -1.0f, 1.0f);
|
||||
glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(-_position, 0.0f));
|
||||
_viewProj = proj * view;
|
||||
}
|
||||
|
||||
const glm::mat4& Camera2D::GetViewProjection() const { return _viewProj; }
|
||||
|
||||
// ——— Renderer ———
|
||||
Renderer::~Renderer() {
|
||||
if (m_quadVAO) glDeleteVertexArrays(1, &m_quadVAO);
|
||||
if (m_quadVBO) glDeleteBuffers(1, &m_quadVBO);
|
||||
if (m_fbo) glDeleteFramebuffers(1, &m_fbo);
|
||||
if (m_colorTex) glDeleteTextures(1, &m_colorTex);
|
||||
if (m_depthRBO) glDeleteRenderbuffers(1, &m_depthRBO);
|
||||
}
|
||||
|
||||
void Renderer::Init(int targetWidth, int targetHeight) {
|
||||
// create offscreen FBO & attachments
|
||||
CreateFramebuffer(targetWidth, targetHeight);
|
||||
|
||||
// compile our sprite shader
|
||||
static const char* vs = R"GLSL(
|
||||
#version 330 core
|
||||
layout(location = 0) in vec2 aPos;
|
||||
layout(location = 1) in vec2 aUV;
|
||||
uniform mat4 u_ViewProj;
|
||||
uniform vec2 u_Offset;
|
||||
uniform vec2 u_Scale;
|
||||
out vec2 vUV;
|
||||
void main(){
|
||||
vUV = aUV;
|
||||
vec2 pos = aPos * u_Scale + u_Offset;
|
||||
gl_Position = u_ViewProj * vec4(pos,0,1);
|
||||
}
|
||||
)GLSL";
|
||||
static const char* fs = R"GLSL(
|
||||
#version 330 core
|
||||
in vec2 vUV;
|
||||
out vec4 FragColor;
|
||||
uniform sampler2D u_Texture;
|
||||
void main(){
|
||||
FragColor = texture(u_Texture, vUV);
|
||||
}
|
||||
)GLSL";
|
||||
|
||||
m_shader.LoadFromSource(vs, fs);
|
||||
|
||||
CreateQuad();
|
||||
}
|
||||
|
||||
void Renderer::CreateFramebuffer(int width, int height) {
|
||||
// cleanup old
|
||||
if (m_fbo) glDeleteFramebuffers(1, &m_fbo);
|
||||
if (m_colorTex) glDeleteTextures(1, &m_colorTex);
|
||||
if (m_depthRBO) glDeleteRenderbuffers(1, &m_depthRBO);
|
||||
|
||||
// color texture
|
||||
glGenTextures(1, &m_colorTex);
|
||||
glBindTexture(GL_TEXTURE_2D, m_colorTex);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height,
|
||||
0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
|
||||
// depth renderbuffer
|
||||
glGenRenderbuffers(1, &m_depthRBO);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, m_depthRBO);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height);
|
||||
|
||||
// framebuffer
|
||||
glGenFramebuffers(1, &m_fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D, m_colorTex, 0);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT,
|
||||
GL_RENDERBUFFER, m_depthRBO);
|
||||
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
|
||||
std::cerr << "[Renderer] Framebuffer not complete!\n";
|
||||
|
||||
// unbind
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void Renderer::ResizeTarget(int width, int height) {
|
||||
CreateFramebuffer(width, height);
|
||||
}
|
||||
|
||||
void Renderer::BeginScene(const Camera2D& camera) {
|
||||
// bind offscreen
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
|
||||
// clear
|
||||
glViewport(0, 0, /*width*/0, /*height*/0); // you may want to track size
|
||||
glClearColor(0,0,0,1);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
|
||||
// setup shader & camera
|
||||
m_viewProj = camera.GetViewProjection();
|
||||
m_shader.Use();
|
||||
m_shader.SetMat4("u_ViewProj", m_viewProj);
|
||||
m_shader.SetInt("u_Texture", 0);
|
||||
}
|
||||
|
||||
void Renderer::DrawSprite(const Sprite& s) {
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, s.textureID);
|
||||
|
||||
m_shader.SetVec2("u_Offset", s.position);
|
||||
m_shader.SetVec2("u_Scale", s.size);
|
||||
|
||||
glBindVertexArray(m_quadVAO);
|
||||
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
|
||||
}
|
||||
|
||||
void Renderer::EndScene() {
|
||||
// unbind FBO → subsequent draws go to default framebuffer
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void Renderer::CreateQuad() {
|
||||
float verts[] = {
|
||||
-0.5f, -0.5f, 0,0,
|
||||
0.5f, -0.5f, 1,0,
|
||||
0.5f, 0.5f, 1,1,
|
||||
-0.5f, 0.5f, 0,1
|
||||
};
|
||||
unsigned int idx[] = {0,1,2, 2,3,0};
|
||||
|
||||
glGenVertexArrays(1, &m_quadVAO);
|
||||
glGenBuffers(1, &m_quadVBO);
|
||||
|
||||
glBindVertexArray(m_quadVAO);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m_quadVBO);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
|
||||
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0,2,GL_FLOAT,GL_FALSE,4*sizeof(float),(void*)0);
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1,2,GL_FLOAT,GL_FALSE,4*sizeof(float),(void*)(2*sizeof(float)));
|
||||
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
|
||||
} // namespace OX
|
||||
|
@ -1,26 +1,73 @@
|
||||
//
|
||||
// Created by spenc on 5/21/2025.
|
||||
//
|
||||
// File: src/Renderer.h
|
||||
#pragma once
|
||||
|
||||
#ifndef RENDERER_H
|
||||
#define RENDERER_H
|
||||
|
||||
#include "glfw/glfw3.h"
|
||||
#include "glm/glm.hpp"
|
||||
#include "systems/Shader.h"
|
||||
#include <gl/glew.h>
|
||||
|
||||
namespace OX {
|
||||
|
||||
class Renderer {
|
||||
public:
|
||||
Renderer() = default;
|
||||
~Renderer() = default;
|
||||
struct Sprite {
|
||||
GLuint textureID;
|
||||
glm::vec2 position;
|
||||
glm::vec2 size;
|
||||
};
|
||||
|
||||
[[nodiscard]] GLuint GetRenderTarget() const {return m_renderTarget; }
|
||||
class Camera2D {
|
||||
public:
|
||||
Camera2D(float left, float right, float bottom, float top);
|
||||
void SetPosition(const glm::vec2& pos);
|
||||
void SetZoom(float zoom);
|
||||
const glm::mat4& GetViewProjection() const;
|
||||
|
||||
private:
|
||||
GLuint m_renderTarget;
|
||||
};
|
||||
private:
|
||||
void Recalculate();
|
||||
glm::vec2 _position{0.0f};
|
||||
float _zoom{1.0f};
|
||||
float _left, _right, _bottom, _top;
|
||||
glm::mat4 _viewProj{1.0f};
|
||||
};
|
||||
|
||||
} // OX
|
||||
class Renderer {
|
||||
public:
|
||||
Renderer() = default;
|
||||
~Renderer();
|
||||
|
||||
#endif //RENDERER_H
|
||||
/// Must call once after OpenGL context + GLEW init.
|
||||
/// Provide the desired offscreen target size here.
|
||||
void Init(int targetWidth, int targetHeight);
|
||||
|
||||
/// Call at start of each scene (binds FBO and sets camera).
|
||||
void BeginScene(const Camera2D& camera);
|
||||
|
||||
/// Draw one sprite into the current scene.
|
||||
void DrawSprite(const Sprite& sprite);
|
||||
|
||||
/// Finish rendering, unbinds FBO.
|
||||
void EndScene();
|
||||
|
||||
/// Access the color‐attachment texture ID that holds the rendered scene.
|
||||
GLuint GetRenderTexture() const { return m_colorTex; }
|
||||
|
||||
/// If window resized, call this to resize the offscreen target.
|
||||
void ResizeTarget(int width, int height);
|
||||
|
||||
private:
|
||||
// offscreen
|
||||
GLuint m_fbo = 0;
|
||||
GLuint m_colorTex = 0;
|
||||
GLuint m_depthRBO = 0;
|
||||
|
||||
// quad geometry
|
||||
GLuint m_quadVAO = 0, m_quadVBO = 0;
|
||||
|
||||
// shader
|
||||
Shader m_shader;
|
||||
glm::mat4 m_viewProj{1.0f};
|
||||
|
||||
// helpers
|
||||
void CreateQuad();
|
||||
void CreateFramebuffer(int width, int height);
|
||||
};
|
||||
|
||||
} // namespace OX
|
||||
|
180
src/core/systems/Shader.cpp
Normal file
180
src/core/systems/Shader.cpp
Normal file
@ -0,0 +1,180 @@
|
||||
#include "Shader.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <iostream>
|
||||
#include "systems/Logger.h"
|
||||
|
||||
namespace OX
|
||||
{
|
||||
|
||||
Shader::Shader(const std::string &vertexPath, const std::string &fragmentPath)
|
||||
{
|
||||
LoadFromFiles(vertexPath, fragmentPath);
|
||||
}
|
||||
|
||||
bool Shader::LoadFromFiles(const std::string &vertexPath, const std::string &fragmentPath)
|
||||
{
|
||||
m_vertexPath = vertexPath;
|
||||
m_fragmentPath = fragmentPath;
|
||||
|
||||
std::string vertexSrc = ReadFile(vertexPath);
|
||||
std::string fragmentSrc = ReadFile(fragmentPath);
|
||||
|
||||
try {
|
||||
m_vertTimestamp = std::filesystem::last_write_time(vertexPath);
|
||||
m_fragTimestamp = std::filesystem::last_write_time(fragmentPath);
|
||||
} catch (...) {
|
||||
std::cerr << "[Shader] Warning: Could not get file timestamps.\n";
|
||||
}
|
||||
|
||||
return LoadFromSource(vertexSrc, fragmentSrc);
|
||||
}
|
||||
|
||||
|
||||
bool Shader::LoadFromSource(const std::string &vertexSrc, const std::string &fragmentSrc)
|
||||
{
|
||||
GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSrc);
|
||||
GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, fragmentSrc);
|
||||
|
||||
if (!vertexShader || !fragmentShader) return false;
|
||||
|
||||
GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vertexShader);
|
||||
glAttachShader(program, fragmentShader);
|
||||
glLinkProgram(program);
|
||||
|
||||
GLint success;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &success);
|
||||
if (!success) {
|
||||
char info[512];
|
||||
glGetProgramInfoLog(program, 512, nullptr, info);
|
||||
std::cerr << "[Shader] Linking failed:\n" << info << '\n';
|
||||
glDeleteProgram(program);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_programID)
|
||||
glDeleteProgram(m_programID);
|
||||
|
||||
m_programID = program;
|
||||
m_uniformCache.clear();
|
||||
|
||||
glDeleteShader(vertexShader);
|
||||
glDeleteShader(fragmentShader);
|
||||
Logger::LogOk("Shader Loaded (%s,%s)", m_vertexPath.c_str(), m_fragmentPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
void Shader::Reload()
|
||||
{
|
||||
OX_PROFILE_FUNCTION();
|
||||
LoadFromFiles(m_vertexPath, m_fragmentPath);
|
||||
}
|
||||
|
||||
void Shader::Use() const
|
||||
{
|
||||
glUseProgram(m_programID);
|
||||
}
|
||||
|
||||
GLuint Shader::CompileShader(GLenum type, const std::string &source)
|
||||
{
|
||||
OX_PROFILE_FUNCTION();
|
||||
GLuint shader = glCreateShader(type);
|
||||
const char *src = source.c_str();
|
||||
glShaderSource(shader, 1, &src, nullptr);
|
||||
glCompileShader(shader);
|
||||
|
||||
GLint success;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
|
||||
if (!success) {
|
||||
char info[512];
|
||||
glGetShaderInfoLog(shader, 512, nullptr, info);
|
||||
|
||||
Logger::LogError("Failed to Compile Shader '%s', '%s'", m_fragmentPath.c_str(), info);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
void Shader::CheckHotReload()
|
||||
{
|
||||
OX_PROFILE_FUNCTION();
|
||||
|
||||
if (m_vertexPath.empty() || m_fragmentPath.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OX_PROFILE_LABEL("Get Write Time");
|
||||
auto vertTime = std::filesystem::last_write_time(m_vertexPath);
|
||||
auto fragTime = std::filesystem::last_write_time(m_fragmentPath);
|
||||
|
||||
if (vertTime != m_vertTimestamp || fragTime != m_fragTimestamp) {
|
||||
std::cout << "[Shader] Reloading shader: " << m_vertexPath << " / " << m_fragmentPath << "\n";
|
||||
Reload();
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
Logger::LogError("Failed to Reload Shader '%s', %s", m_vertexPath.c_str(), e.what());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string Shader::ReadFile(const std::string &path)
|
||||
{
|
||||
if (!std::filesystem::exists(path)) {
|
||||
std::ofstream file(path, std::ios::app);
|
||||
}
|
||||
std::ifstream file(path);
|
||||
std::stringstream ss;
|
||||
ss << file.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
GLint Shader::GetUniformLocation(const std::string &name)
|
||||
{
|
||||
if (m_uniformCache.contains(name))
|
||||
return m_uniformCache[name];
|
||||
|
||||
GLint location = glGetUniformLocation(m_programID, name.c_str());
|
||||
m_uniformCache[name] = location;
|
||||
return location;
|
||||
}
|
||||
|
||||
void Shader::SetInt(const std::string &name, int value)
|
||||
{
|
||||
glUniform1i(GetUniformLocation(name), value);
|
||||
}
|
||||
|
||||
void Shader::SetFloat(const std::string &name, float value)
|
||||
{
|
||||
glUniform1f(GetUniformLocation(name), value);
|
||||
}
|
||||
|
||||
void Shader::SetVec2(const std::string &name, const glm::vec2 &value)
|
||||
{
|
||||
glUniform2fv(GetUniformLocation(name), 1, &value[0]);
|
||||
}
|
||||
|
||||
void Shader::SetVec3(const std::string &name, const glm::vec3 &value)
|
||||
{
|
||||
glUniform3fv(GetUniformLocation(name), 1, &value[0]);
|
||||
}
|
||||
|
||||
void Shader::SetVec4(const std::string &name, const glm::vec4 &value)
|
||||
{
|
||||
glUniform4fv(GetUniformLocation(name), 1, &value[0]);
|
||||
}
|
||||
|
||||
void Shader::SetMat4(const std::string &name, const glm::mat4 &value)
|
||||
{
|
||||
glUniformMatrix4fv(GetUniformLocation(name), 1, GL_FALSE, &value[0][0]);
|
||||
}
|
||||
|
||||
|
||||
void Shader::SetBool(const std::string &name, bool value)
|
||||
{
|
||||
glUniform1i(GetUniformLocation(name), (int) value);
|
||||
}
|
||||
}
|
61
src/core/systems/Shader.h
Normal file
61
src/core/systems/Shader.h
Normal file
@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <glm/glm.hpp>
|
||||
#include <GL/glew.h>
|
||||
#include <filesystem>
|
||||
#include "systems/Profiler.h"
|
||||
|
||||
namespace OX
|
||||
{
|
||||
class Shader
|
||||
{
|
||||
public:
|
||||
Shader() = default;
|
||||
|
||||
Shader(const std::string &vertexPath, const std::string &fragmentPath);
|
||||
|
||||
bool LoadFromFiles(const std::string &vertexPath, const std::string &fragmentPath);
|
||||
|
||||
bool LoadFromSource(const std::string &vertexSrc, const std::string &fragmentSrc);
|
||||
|
||||
void Reload();
|
||||
|
||||
void Use() const;
|
||||
|
||||
void CheckHotReload(); // 🔥
|
||||
|
||||
GLuint GetID() const { return m_programID; }
|
||||
|
||||
void SetInt(const std::string &name, int value);
|
||||
|
||||
void SetFloat(const std::string &name, float value);
|
||||
|
||||
void SetVec2(const std::string &name, const glm::vec2 &value);
|
||||
|
||||
void SetVec3(const std::string &name, const glm::vec3 &value);
|
||||
|
||||
void SetVec4(const std::string &name, const glm::vec4 &value);
|
||||
|
||||
void SetMat4(const std::string &name, const glm::mat4 &value);
|
||||
|
||||
void SetBool(const std::string &name, const bool value);
|
||||
|
||||
private:
|
||||
GLuint m_programID = 0;
|
||||
std::string m_vertexPath;
|
||||
std::string m_fragmentPath;
|
||||
|
||||
std::unordered_map<std::string, GLint> m_uniformCache;
|
||||
|
||||
std::filesystem::file_time_type m_vertTimestamp;
|
||||
std::filesystem::file_time_type m_fragTimestamp;
|
||||
|
||||
GLuint CompileShader(GLenum type, const std::string &source);
|
||||
|
||||
std::string ReadFile(const std::string &path);
|
||||
|
||||
GLint GetUniformLocation(const std::string &name);
|
||||
};
|
||||
}
|
@ -13,6 +13,8 @@ namespace OX
|
||||
{
|
||||
// Construct title
|
||||
std::string title = m_name + " (" + std::to_string(m_id) + ")";
|
||||
GLuint textureID = core.GetRenderer().GetRenderTexture(); // You must define this method
|
||||
|
||||
|
||||
// Remove window padding
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
@ -24,14 +26,17 @@ namespace OX
|
||||
ImVec2 viewportSize = ImGui::GetContentRegionAvail();
|
||||
|
||||
// === Fullscreen image ===
|
||||
GLuint textureID = core.GetRenderer().GetRenderTarget(); // You must define this method
|
||||
if (textureID != 0) {
|
||||
ImGui::Image(textureID, viewportSize, ImVec2(0, 1), ImVec2(1, 0));
|
||||
} else {
|
||||
ImGui::Text("No Render Target.");
|
||||
}
|
||||
|
||||
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(); // Restore padding
|
||||
|
||||
core.GetRenderer().ResizeTarget(viewportSize.x, viewportSize.y);
|
||||
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user