feat: add grass and terrain
This commit is contained in:
+501
-50
@@ -1,24 +1,464 @@
|
||||
'use client';
|
||||
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { Canvas, useFrame, useLoader } from '@react-three/fiber';
|
||||
import {
|
||||
Bloom,
|
||||
EffectComposer,
|
||||
GodRays,
|
||||
Noise
|
||||
} from '@react-three/postprocessing';
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas, useLoader, useFrame } from '@react-three/fiber';
|
||||
import { forwardRef, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { Mesh, TextureLoader } from 'three';
|
||||
import { BufferAttribute, BufferGeometry, Mesh, Object3D, InstancedMesh, DoubleSide, TextureLoader, Color, MeshStandardMaterial } from 'three';
|
||||
|
||||
import './page.css';
|
||||
import { createNoise2D } from 'simplex-noise';
|
||||
import { useControls } from 'leva';
|
||||
|
||||
const SealCube = forwardRef<Mesh>((props, ref) => {
|
||||
const texture = useLoader(TextureLoader, '/img/niko.jpg');
|
||||
import grassVert from './shaders/grass.vert';
|
||||
import grassFrag from './shaders/grass.frag';
|
||||
import { Bloom, EffectComposer } from '@react-three/postprocessing';
|
||||
|
||||
interface Shader {
|
||||
uniforms: { [key: string]: { value: unknown } };
|
||||
vertexShader: string;
|
||||
fragmentShader: string;
|
||||
}
|
||||
|
||||
function getTerrainHeight(
|
||||
localX: number,
|
||||
localZ: number,
|
||||
worldXBase: number,
|
||||
worldZBase: number,
|
||||
scale: number,
|
||||
hillScale: number,
|
||||
hillHeight: number,
|
||||
detailScale: number,
|
||||
detailHeight: number,
|
||||
noise2D: (x: number, y: number) => number
|
||||
) {
|
||||
const worldX = (worldXBase + localX) * 0.1;
|
||||
const worldZ = (worldZBase + localZ) * 0.1;
|
||||
|
||||
const noiseHill = noise2D(worldX * hillScale, worldZ * hillScale) * hillHeight;
|
||||
const noiseDetail = noise2D(worldX * detailScale, worldZ * detailScale) * detailHeight;
|
||||
|
||||
return (noiseHill + noiseDetail) * scale;
|
||||
}
|
||||
|
||||
interface GrassProps {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
count: number;
|
||||
grassSize: number;
|
||||
scale: number;
|
||||
hillScale: number;
|
||||
hillHeight: number;
|
||||
detailScale: number;
|
||||
detailHeight: number;
|
||||
noise2D: (x: number, y: number) => number;
|
||||
enableShadows?: boolean;
|
||||
}
|
||||
|
||||
function Grass({ x, y, size, count, grassSize, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D }: GrassProps) {
|
||||
const meshRef = useRef<InstancedMesh>(null);
|
||||
const dummyRef = useRef<Object3D>(new Object3D());
|
||||
|
||||
const [alphaMap, normalMap] = useLoader(TextureLoader, ['/img/grass_alpha.png', '/img/grass_normal.png']);
|
||||
|
||||
const materialRef = useRef<MeshStandardMaterial & { userData: { shader: Shader } }>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (materialRef.current && materialRef.current.userData && materialRef.current.userData.shader) {
|
||||
(materialRef.current.userData.shader as Shader).uniforms.uTime.value = state.clock.getElapsedTime();
|
||||
}
|
||||
});
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const geo = new BufferGeometry();
|
||||
|
||||
const w = 0.5;
|
||||
const h = 2;
|
||||
|
||||
const positions = [
|
||||
-w, 0, 0, w, 0, 0, -w, h, 0, w, h, 0,
|
||||
0, 0, w, 0, 0, -w, 0, h, w, 0, h, -w
|
||||
];
|
||||
|
||||
const uvs = [
|
||||
0, 0, 1, 0, 0, 1, 1, 1,
|
||||
0, 0, 1, 0, 0, 1, 1, 1
|
||||
];
|
||||
|
||||
const indices = [
|
||||
0, 1, 2, 2, 1, 3,
|
||||
4, 5, 6, 6, 5, 7
|
||||
];
|
||||
|
||||
geo.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3));
|
||||
geo.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2));
|
||||
geo.setIndex(indices);
|
||||
geo.computeVertexNormals();
|
||||
|
||||
return geo;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!meshRef.current) return;
|
||||
const dummy = dummyRef.current;
|
||||
|
||||
const worldXBase = x * size;
|
||||
const worldZBase = y * size;
|
||||
|
||||
const color = new Color();
|
||||
const lushColor = new Color('#03ce03');
|
||||
const dryColor = new Color('#91c404');
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const localX = (Math.random() - 0.5) * size;
|
||||
const localZ = (Math.random() - 0.5) * size;
|
||||
|
||||
const localY = getTerrainHeight(
|
||||
localX,
|
||||
localZ,
|
||||
worldXBase,
|
||||
worldZBase,
|
||||
scale,
|
||||
hillScale,
|
||||
hillHeight,
|
||||
detailScale,
|
||||
detailHeight,
|
||||
noise2D
|
||||
);
|
||||
|
||||
dummy.position.set(localX, localY, localZ);
|
||||
|
||||
dummy.rotation.y = Math.random() * Math.PI * 2;
|
||||
dummy.rotation.x = (Math.random() - 0.5) * 0.2;
|
||||
dummy.rotation.z = (Math.random() - 0.5) * 0.2;
|
||||
|
||||
const baseScale = grassSize + (Math.random() * grassSize * 0.5);
|
||||
const heightMult = 0.5 + Math.random() * 1.0;
|
||||
dummy.scale.set(baseScale, baseScale * heightMult, baseScale);
|
||||
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||
|
||||
const globalX = worldXBase + localX;
|
||||
const globalZ = worldZBase + localZ;
|
||||
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
|
||||
|
||||
const t = (noiseVal + 1) / 2;
|
||||
|
||||
const randomInternal = (Math.random() - 0.5) * 0.2;
|
||||
const finalT = Math.max(0, Math.min(1, t + randomInternal));
|
||||
|
||||
color.lerpColors(dryColor, lushColor, finalT);
|
||||
|
||||
meshRef.current.setColorAt(i, color);
|
||||
}
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
if (meshRef.current.instanceColor) meshRef.current.instanceColor.needsUpdate = true;
|
||||
}, [x, y, size, count, grassSize, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D]);
|
||||
|
||||
const onBeforeCompile = useMemo(() => (shader: Shader) => {
|
||||
shader.uniforms.uTime = { value: 0 };
|
||||
|
||||
shader.vertexShader = `
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
float frequency = 1.0;
|
||||
|
||||
for(int i = 0; i < 4; i++) {
|
||||
value += amplitude * noise(p * frequency);
|
||||
frequency *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
${shader.vertexShader}
|
||||
`;
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
'#include <begin_vertex>',
|
||||
`
|
||||
#include <begin_vertex>
|
||||
vGrassUv = uv;
|
||||
${grassVert}
|
||||
`
|
||||
);
|
||||
|
||||
shader.fragmentShader = `
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
${shader.fragmentShader}
|
||||
`;
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
'#include <color_fragment>',
|
||||
`
|
||||
#include <color_fragment>
|
||||
${grassFrag}
|
||||
`
|
||||
);
|
||||
|
||||
if (materialRef.current) {
|
||||
materialRef.current.userData.shader = shader;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
ref={meshRef}
|
||||
args={[undefined, undefined, count]}
|
||||
position={[x * size, 0, y * size]}
|
||||
geometry={geometry}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
ref={materialRef}
|
||||
color="#ffffff"
|
||||
side={DoubleSide}
|
||||
alphaMap={alphaMap}
|
||||
alphaTest={0.5}
|
||||
normalMap={normalMap}
|
||||
roughness={0.8}
|
||||
metalness={0.1}
|
||||
onBeforeCompile={onBeforeCompile}
|
||||
/>
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
interface TerrainChunkProps {
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
resolution: number,
|
||||
scale: number,
|
||||
hillScale: number,
|
||||
hillHeight: number,
|
||||
detailScale: number,
|
||||
detailHeight: number,
|
||||
noise2D: (x: number, y: number) => number;
|
||||
wireframe?: boolean;
|
||||
grassCount: number;
|
||||
grassSize: number;
|
||||
grassLOD: number;
|
||||
}
|
||||
function TerrainChunk({ x, y, size, resolution, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D, wireframe = false, grassCount, grassSize, grassLOD }: TerrainChunkProps) {
|
||||
const distance = Math.sqrt((x * size) ** 2 + (y * size) ** 2);
|
||||
|
||||
let adjustedGrassCount = grassCount;
|
||||
if (distance > grassLOD) {
|
||||
adjustedGrassCount = 0;
|
||||
} else if (distance > grassLOD * 0.6) {
|
||||
const fadeStart = grassLOD * 0.6;
|
||||
const fadeRange = grassLOD * 0.4;
|
||||
const fadeFactor = 1.0 - ((distance - fadeStart) / fadeRange);
|
||||
adjustedGrassCount = Math.floor(grassCount * fadeFactor * fadeFactor);
|
||||
}
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
useImperativeHandle(ref, () => meshRef.current!, []);
|
||||
const geometry = useMemo(() => {
|
||||
const geo = new BufferGeometry();
|
||||
|
||||
const vertices: Array<number> = [];
|
||||
const indices: Array<number> = [];
|
||||
|
||||
const step = size / (resolution - 1);
|
||||
const halfSize = size / 2;
|
||||
const worldXBase = x * size;
|
||||
const worldZBase = y * size;
|
||||
|
||||
/*
|
||||
vtx gen
|
||||
*/
|
||||
for (let iz = 0; iz < resolution; iz++) {
|
||||
for (let ix = 0; ix < resolution; ix++) {
|
||||
const localX = ix * step - halfSize;
|
||||
const localZ = iz * step - halfSize;
|
||||
|
||||
const localY = getTerrainHeight(
|
||||
localX,
|
||||
localZ,
|
||||
worldXBase,
|
||||
worldZBase,
|
||||
scale,
|
||||
hillScale,
|
||||
hillHeight,
|
||||
detailScale,
|
||||
detailHeight,
|
||||
noise2D
|
||||
);
|
||||
|
||||
vertices.push(localX, localY, localZ);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
idx gen
|
||||
*/
|
||||
for (let iz = 0; iz < resolution - 1; iz++) {
|
||||
for (let ix = 0; ix < resolution - 1; ix++) {
|
||||
const topLeft = iz * resolution + ix;
|
||||
const topRight = topLeft + 1;
|
||||
const bottomLeft = (iz + 1) * resolution + ix;
|
||||
const bottomRight = bottomLeft + 1;
|
||||
|
||||
indices.push(topLeft, bottomLeft, topRight);
|
||||
indices.push(topRight, bottomLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
geo.setAttribute('position', new BufferAttribute(new Float32Array(vertices), 3));
|
||||
|
||||
const colors: Array<number> = [];
|
||||
for (let iz = 0; iz < resolution; iz++) {
|
||||
for (let ix = 0; ix < resolution; ix++) {
|
||||
const localX = ix * step - halfSize;
|
||||
const localZ = iz * step - halfSize;
|
||||
const globalX = worldXBase + localX;
|
||||
const globalZ = worldZBase + localZ;
|
||||
const colorNoise = noise2D(globalX * 0.02, globalZ * 0.02);
|
||||
const t = (colorNoise + 1) / 2;
|
||||
|
||||
const dryGreen = { r: 0.1, g: 0.3, b: 0.0 };
|
||||
const lushGreen = { r: 0.0, g: 0.4, b: 0.0 };
|
||||
|
||||
const r = dryGreen.r + (lushGreen.r - dryGreen.r) * t;
|
||||
const g = dryGreen.g + (lushGreen.g - dryGreen.g) * t;
|
||||
const b = dryGreen.b + (lushGreen.b - dryGreen.b) * t;
|
||||
|
||||
colors.push(r, g, b);
|
||||
}
|
||||
}
|
||||
geo.setAttribute('color', new BufferAttribute(new Float32Array(colors), 3));
|
||||
|
||||
geo.setIndex(indices);
|
||||
geo.computeVertexNormals();
|
||||
|
||||
return geo;
|
||||
}, [x, y, size, resolution, scale, hillScale, hillHeight, detailScale, detailHeight, noise2D]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
geometry={geometry}
|
||||
position={[x * size, 0, y * size]}
|
||||
// receiveShadow
|
||||
// castShadow
|
||||
>
|
||||
<meshStandardMaterial
|
||||
vertexColors
|
||||
roughness={0.9}
|
||||
metalness={0.1}
|
||||
wireframe={wireframe}
|
||||
/>
|
||||
</mesh>
|
||||
{!wireframe && adjustedGrassCount > 0 && (
|
||||
<Grass
|
||||
x={x}
|
||||
y={y}
|
||||
size={size}
|
||||
count={adjustedGrassCount}
|
||||
grassSize={grassSize}
|
||||
scale={scale}
|
||||
hillScale={hillScale}
|
||||
hillHeight={hillHeight}
|
||||
detailScale={detailScale}
|
||||
detailHeight={detailHeight}
|
||||
noise2D={noise2D}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
interface TerrainProps {
|
||||
chunks?: number;
|
||||
chunkSize?: number;
|
||||
resolution?: number;
|
||||
scale?: number;
|
||||
hillScale: number,
|
||||
hillHeight: number,
|
||||
detailScale: number,
|
||||
detailHeight: number,
|
||||
wireframe?: boolean;
|
||||
grassCount?: number;
|
||||
grassSize?: number;
|
||||
grassLOD?: number;
|
||||
}
|
||||
function Terrain({
|
||||
chunks = 5,
|
||||
chunkSize = 10,
|
||||
resolution = 8,
|
||||
scale = 1,
|
||||
hillScale = 0.2,
|
||||
hillHeight = 3,
|
||||
detailScale = 3,
|
||||
detailHeight = 0.1,
|
||||
wireframe = false,
|
||||
grassCount = 6000,
|
||||
grassSize = 0.6,
|
||||
grassLOD = 40
|
||||
}: TerrainProps) {
|
||||
const noise2D = useMemo(() => createNoise2D(), []);
|
||||
const offset = -Math.floor(chunks / 2);
|
||||
|
||||
const chunkPositions = useMemo(() => {
|
||||
const positions: [number, number][] = [];
|
||||
for (let x = 0; x < chunks; x++)
|
||||
for (let y = 0; y < chunks; y++)
|
||||
positions.push([x + offset, y + offset]);
|
||||
|
||||
return positions;
|
||||
}, [chunks, offset]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{chunkPositions.map(([x, y], index) => (
|
||||
<TerrainChunk
|
||||
key={`${x}-${y}-${index}`}
|
||||
x={x}
|
||||
y={y}
|
||||
size={chunkSize}
|
||||
resolution={resolution}
|
||||
scale={scale}
|
||||
hillScale={hillScale}
|
||||
hillHeight={hillHeight}
|
||||
detailScale={detailScale}
|
||||
detailHeight={detailHeight}
|
||||
noise2D={noise2D}
|
||||
wireframe={wireframe}
|
||||
grassCount={grassCount}
|
||||
grassSize={grassSize}
|
||||
grassLOD={grassLOD}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SealCube() {
|
||||
const texture = useLoader(TextureLoader, '/img/niko.jpg');
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (meshRef.current) {
|
||||
@@ -28,59 +468,70 @@ const SealCube = forwardRef<Mesh>((props, ref) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} castShadow receiveShadow {...props}>
|
||||
<mesh position={[0, 5, 0]} ref={meshRef} castShadow receiveShadow>
|
||||
<boxGeometry args={[2.5, 2.5, 2.5]} />
|
||||
<meshBasicMaterial map={texture} />
|
||||
</mesh>
|
||||
);
|
||||
});
|
||||
SealCube.displayName = 'SealCube';
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [sunMesh, setSunMesh] = useState<Mesh | null>(null);
|
||||
// const { chunks, chunkSize, chunkRes, terrainScale, hillScale, hillHeight, detailScale, detailHeight, wireframe, grassCount, grassSize, grassLOD } = useControls(
|
||||
// {
|
||||
// chunks: 8,
|
||||
// chunkSize: 10,
|
||||
// chunkRes: { value: 8, min: 2, max: 64, step: 1 },
|
||||
// terrainScale: 1,
|
||||
// hillScale: 0.1,
|
||||
// hillHeight: 6.0,
|
||||
// detailScale: 1.0,
|
||||
// detailHeight: 0.1,
|
||||
// wireframe: false,
|
||||
// grassCount: { value: 6000, min: 0, max: 10000, step: 100 },
|
||||
// grassSize: { value: 0.6, min: 0.01, max: 1.0, step: 0.01 },
|
||||
// grassLOD: { value: 40, min: 10, max: 100, step: 5, label: 'Grass LOD Distance' },
|
||||
// }
|
||||
// );
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: [5, 5, 5], fov: 50 }}
|
||||
camera={{ position: [0, 5, 15], fov: 50 }}
|
||||
gl={{ antialias: true }}
|
||||
className='canvas'
|
||||
>
|
||||
<SealCube
|
||||
ref={(mesh) => {
|
||||
if (mesh && !sunMesh) {
|
||||
setSunMesh(mesh);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<EffectComposer>
|
||||
<Noise opacity={0.1} />
|
||||
<Bloom
|
||||
intensity={2}
|
||||
luminanceThreshold={0.5}
|
||||
luminanceSmoothing={0.1}
|
||||
/>
|
||||
{
|
||||
sunMesh ? (
|
||||
<GodRays
|
||||
sun={sunMesh}
|
||||
samples={30}
|
||||
density={1.5}
|
||||
decay={0.8}
|
||||
weight={0.2}
|
||||
exposure={0.5}
|
||||
clampMax={1}
|
||||
blur={true}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
// it feels genuinely so cursed to have to do : <></> just because EffectComposer can't handle null
|
||||
}
|
||||
<Bloom />
|
||||
</EffectComposer>
|
||||
|
||||
<OrbitControls />
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight
|
||||
position={[10, 20, 5]}
|
||||
intensity={1}
|
||||
/>
|
||||
<Terrain
|
||||
chunks={8.0}
|
||||
chunkSize={10.0}
|
||||
resolution={8.0}
|
||||
scale={1}
|
||||
hillScale={0.1}
|
||||
hillHeight={6.0}
|
||||
detailScale={1.0}
|
||||
detailHeight={0.1}
|
||||
wireframe={false}
|
||||
grassCount={6000}
|
||||
grassSize={0.6}
|
||||
grassLOD={80}
|
||||
/>
|
||||
|
||||
<SealCube />
|
||||
<OrbitControls
|
||||
target={[0, 5, 0]}
|
||||
enablePan={false}
|
||||
makeDefault
|
||||
minDistance={5}
|
||||
maxDistance={30}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
vec3 rootColor = diffuseColor.rgb * 0.5;
|
||||
vec3 tipColor = diffuseColor.rgb * 1.5 + vec3(0.1, 0.1, 0.0); // Slight yellow tint at tips
|
||||
|
||||
diffuseColor.rgb = mix(rootColor, tipColor, vGrassUv.y);
|
||||
@@ -0,0 +1,25 @@
|
||||
float worldX = instanceMatrix[3][0];
|
||||
float worldZ = instanceMatrix[3][2];
|
||||
|
||||
float windSpeed = 0.3;
|
||||
float windScale = 0.04;
|
||||
|
||||
vec2 windPos = vec2(worldX, worldZ) * windScale;
|
||||
vec2 windFlow = windPos + vec2(uTime * windSpeed, uTime * windSpeed * 0.7);
|
||||
|
||||
float windStrength = fbm(windFlow);
|
||||
windStrength = windStrength * 0.6 + 0.3;
|
||||
|
||||
float turbulence = fbm(windPos * 3.0 + uTime * 2.0) * 0.15;
|
||||
|
||||
vec2 windDir = vec2(
|
||||
fbm(windPos * 0.5 + vec2(uTime * 0.1, 0.0)),
|
||||
fbm(windPos * 0.5 + vec2(0.0, uTime * 0.1))
|
||||
);
|
||||
windDir = normalize(windDir - 0.5) * 0.8;
|
||||
|
||||
float swayAmount = (windStrength + turbulence) * uv.y * uv.y;
|
||||
|
||||
transformed.x += swayAmount * windDir.x;
|
||||
transformed.z += swayAmount * windDir.y;
|
||||
transformed.y -= abs(swayAmount) * 0.2;
|
||||
Reference in New Issue
Block a user