--global-fix
This commit is contained in:
489
my3dengine/mesh.py
Normal file
489
my3dengine/mesh.py
Normal file
@@ -0,0 +1,489 @@
|
||||
from OpenGL.GL import *
|
||||
import numpy as np
|
||||
from .shader import basic_shader
|
||||
from PIL import Image
|
||||
import ctypes
|
||||
import math
|
||||
import time
|
||||
|
||||
_default_shader = None
|
||||
_last_bound_shader = [None]
|
||||
_last_bound_vao = [None]
|
||||
|
||||
def get_default_shader():
|
||||
global _default_shader
|
||||
if _default_shader is None:
|
||||
_default_shader = basic_shader()
|
||||
_default_shader.uniforms = {
|
||||
"uModel": glGetUniformLocation(_default_shader.program, "uModel"),
|
||||
"uUVTiling": glGetUniformLocation(_default_shader.program, "uUVTiling"),
|
||||
"uUVOffset": glGetUniformLocation(_default_shader.program, "uUVOffset"),
|
||||
"uTexture": glGetUniformLocation(_default_shader.program, "uTexture"),
|
||||
"useTexture": glGetUniformLocation(_default_shader.program, "useTexture"),
|
||||
}
|
||||
return _default_shader
|
||||
|
||||
def safe_use_shader(shader):
|
||||
if _last_bound_shader[0] != shader:
|
||||
glUseProgram(shader.program)
|
||||
_last_bound_shader[0] = shader
|
||||
|
||||
def safe_bind_vao(vao):
|
||||
if _last_bound_vao[0] != vao:
|
||||
glBindVertexArray(vao)
|
||||
_last_bound_vao[0] = vao
|
||||
|
||||
def get_model_matrix(position, scale):
|
||||
model = np.identity(4, dtype=np.float32)
|
||||
model[0, 0], model[1, 1], model[2, 2] = scale
|
||||
model[:3, 3] = position # poprawione na kolumnę 3
|
||||
return model
|
||||
|
||||
class Mesh:
|
||||
def __init__(self, vertices, shader=None):
|
||||
self.shader = shader or get_default_shader()
|
||||
if not hasattr(self.shader, "uniforms"):
|
||||
self.shader.uniforms = {
|
||||
"uModel": glGetUniformLocation(self.shader.program, "uModel"),
|
||||
"uUVTiling": glGetUniformLocation(self.shader.program, "uUVTiling"),
|
||||
"uUVOffset": glGetUniformLocation(self.shader.program, "uUVOffset"),
|
||||
"uTexture": glGetUniformLocation(self.shader.program, "uTexture"),
|
||||
"useTexture": glGetUniformLocation(self.shader.program, "useTexture"),
|
||||
}
|
||||
|
||||
self.vertex_count = len(vertices) // 8
|
||||
self.position = np.zeros(3, dtype=np.float32)
|
||||
self.scale = np.ones(3, dtype=np.float32)
|
||||
self.texture = None
|
||||
self.uv_tiling = np.ones(2, dtype=np.float32)
|
||||
self.uv_offset = np.zeros(2, dtype=np.float32)
|
||||
|
||||
self._last_position = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
|
||||
self._last_scale = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
|
||||
|
||||
self._uniforms_uploaded_once = False
|
||||
|
||||
self._init_buffers(vertices)
|
||||
|
||||
def copy(self):
|
||||
new = Mesh.__new__(Mesh)
|
||||
new.__dict__ = self.__dict__.copy()
|
||||
new.position = self.position.copy()
|
||||
new.scale = self.scale.copy()
|
||||
new._last_position = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
|
||||
new._last_scale = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
|
||||
|
||||
self._uniforms_uploaded_once = False
|
||||
return new
|
||||
|
||||
def is_in_fov(self, cam_pos, cam_dir, fov_deg):
|
||||
half_fov_rad = math.radians(fov_deg * 1.2)
|
||||
cam_dir = cam_dir / np.linalg.norm(cam_dir)
|
||||
to_obj = self.position - cam_pos
|
||||
dist = np.linalg.norm(to_obj)
|
||||
if dist == 0:
|
||||
return True
|
||||
to_obj_dir = to_obj / dist
|
||||
angle = math.acos(np.clip(np.dot(cam_dir, to_obj_dir), -1, 1))
|
||||
return angle <= half_fov_rad
|
||||
|
||||
def _init_buffers(self, vertices):
|
||||
data = np.array(vertices, dtype=np.float32)
|
||||
self.vao = glGenVertexArrays(1)
|
||||
self.vbo = glGenBuffers(1)
|
||||
|
||||
glBindVertexArray(self.vao)
|
||||
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
|
||||
glBufferData(GL_ARRAY_BUFFER, data.nbytes, data, GL_STATIC_DRAW)
|
||||
|
||||
stride = 8 * 4
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
|
||||
glEnableVertexAttribArray(0)
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12))
|
||||
glEnableVertexAttribArray(1)
|
||||
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24))
|
||||
glEnableVertexAttribArray(2)
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
||||
glBindVertexArray(0)
|
||||
|
||||
def set_uv_transform(self, tiling=(1.0, 1.0), offset=(0.0, 0.0)):
|
||||
self.uv_tiling[:] = tiling
|
||||
self.uv_offset[:] = offset
|
||||
|
||||
def upload_uniforms(self):
|
||||
if (not self._uniforms_uploaded_once or
|
||||
not (np.array_equal(self.position, self._last_position) and
|
||||
np.array_equal(self.scale, self._last_scale))):
|
||||
model = get_model_matrix(self.position, self.scale)
|
||||
uniforms = self.shader.uniforms
|
||||
glUniformMatrix4fv(uniforms["uModel"], 1, GL_FALSE, model.T)
|
||||
glUniform2fv(uniforms["uUVTiling"], 1, self.uv_tiling)
|
||||
glUniform2fv(uniforms["uUVOffset"], 1, self.uv_offset)
|
||||
|
||||
self._last_position = self.position.copy()
|
||||
self._last_scale = self.scale.copy()
|
||||
self._uniforms_uploaded_once = True
|
||||
|
||||
def draw(self, cam_pos=None, cam_dir=None, fov_deg=70, view_proj_matrix=None, debug=False):
|
||||
if debug:
|
||||
print(f"Draw called for mesh at {self.position}")
|
||||
|
||||
if cam_pos is not None:
|
||||
dist = np.linalg.norm(self.position - cam_pos)
|
||||
if dist > 600: # ustaw granicę zależnie od sceny
|
||||
return
|
||||
|
||||
# if cam_pos is not None and cam_dir is not None:
|
||||
# if not self.is_in_fov(cam_pos, cam_dir, fov_deg + 50): # +10° tolerancji
|
||||
# return
|
||||
|
||||
safe_use_shader(self.shader)
|
||||
self.upload_uniforms()
|
||||
|
||||
uniforms = self.shader.uniforms
|
||||
if self.texture:
|
||||
glActiveTexture(GL_TEXTURE0)
|
||||
glBindTexture(GL_TEXTURE_2D, self.texture)
|
||||
glUniform1i(uniforms["uTexture"], 0)
|
||||
glUniform1i(uniforms["useTexture"], 1)
|
||||
else:
|
||||
glUniform1i(uniforms["useTexture"], 0)
|
||||
|
||||
safe_bind_vao(self.vao)
|
||||
glDrawArrays(GL_TRIANGLES, 0, self.vertex_count)
|
||||
|
||||
if debug:
|
||||
print(f"[DEBUG] Mesh at {self.position} został narysowany.")
|
||||
|
||||
def set_texture(self, path):
|
||||
image = Image.open(path).transpose(Image.FLIP_TOP_BOTTOM).convert('RGBA')
|
||||
img_data = np.array(image, dtype=np.uint8)
|
||||
|
||||
if self.texture:
|
||||
glDeleteTextures(1, [self.texture])
|
||||
|
||||
self.texture = glGenTextures(1)
|
||||
glBindTexture(GL_TEXTURE_2D, self.texture)
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, img_data)
|
||||
glGenerateMipmap(GL_TEXTURE_2D)
|
||||
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
||||
|
||||
def set_position(self, x, y, z):
|
||||
self.position[:] = [x, y, z]
|
||||
|
||||
def set_scale(self, x, y, z):
|
||||
self.scale[:] = [x, y, z]
|
||||
|
||||
def scale_uniform(self, factor):
|
||||
self.scale[:] = factor
|
||||
|
||||
def destroy(self):
|
||||
if hasattr(self, 'vao') and glDeleteVertexArrays:
|
||||
glDeleteVertexArrays(1, [self.vao])
|
||||
glDeleteBuffers(1, [self.vbo])
|
||||
if self.texture and glDeleteTextures:
|
||||
glDeleteTextures(1, [self.texture])
|
||||
|
||||
|
||||
# =========================
|
||||
# ======== models =========
|
||||
# =========================
|
||||
|
||||
@staticmethod
|
||||
def from_obj(path, color=(1, 1, 1)):
|
||||
positions = []
|
||||
texcoords = []
|
||||
faces = []
|
||||
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("v "): # vertex position
|
||||
parts = line.strip().split()
|
||||
positions.append(tuple(map(float, parts[1:4])))
|
||||
elif line.startswith("vt "): # texture coordinate
|
||||
parts = line.strip().split()
|
||||
texcoords.append(tuple(map(float, parts[1:3])))
|
||||
elif line.startswith("f "): # face
|
||||
parts = line.strip().split()[1:]
|
||||
face = []
|
||||
for p in parts:
|
||||
vals = p.split('/')
|
||||
vi = int(vals[0]) - 1
|
||||
ti = int(vals[1]) - 1 if len(vals) > 1 and vals[1] else 0
|
||||
face.append((vi, ti))
|
||||
faces.append(face)
|
||||
|
||||
verts = []
|
||||
for face in faces:
|
||||
if len(face) >= 3:
|
||||
for i in range(1, len(face) - 1):
|
||||
for idx in [0, i, i + 1]:
|
||||
vi, ti = face[idx]
|
||||
pos = positions[vi]
|
||||
uv = texcoords[ti] if texcoords else (0.0, 0.0)
|
||||
verts.extend([*pos, *color, *uv])
|
||||
|
||||
return Mesh(verts, basic_shader())
|
||||
|
||||
@staticmethod
|
||||
def cube(color=(1, 1, 1)):
|
||||
# Pozycje wierzchołków sześcianu
|
||||
p = [(-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5), # front
|
||||
(-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (-0.5, 0.5, -0.5)] # back
|
||||
|
||||
# UV mapowanie na jedną ścianę (powtarzane dla każdej)
|
||||
uv = [(0, 0), (1, 0), (1, 1), (0, 1)]
|
||||
|
||||
# Indeksy do rysowania ścian (każda ściana: 2 trójkąty)
|
||||
faces = [
|
||||
(0, 1, 2, 3), # front
|
||||
(5, 4, 7, 6), # back
|
||||
(4, 0, 3, 7), # left
|
||||
(1, 5, 6, 2), # right
|
||||
(3, 2, 6, 7), # top
|
||||
(4, 5, 1, 0) # bottom
|
||||
]
|
||||
|
||||
verts = []
|
||||
for face in faces:
|
||||
idx = [face[0], face[1], face[2], face[0], face[2], face[3]]
|
||||
uv_idx = [0, 1, 2, 0, 2, 3]
|
||||
for i in range(6):
|
||||
pos = p[idx[i]]
|
||||
tex = uv[uv_idx[i]]
|
||||
verts.extend([*pos, *color, *tex])
|
||||
|
||||
return Mesh(verts, basic_shader())
|
||||
|
||||
@staticmethod
|
||||
def sphere(radius=0.5, lat_segments=16, lon_segments=16, color=(1, 1, 1)):
|
||||
verts = []
|
||||
for i in range(lat_segments):
|
||||
theta1 = np.pi * (i / lat_segments - 0.5)
|
||||
theta2 = np.pi * ((i + 1) / lat_segments - 0.5)
|
||||
for j in range(lon_segments):
|
||||
phi1 = 2 * np.pi * (j / lon_segments)
|
||||
phi2 = 2 * np.pi * ((j + 1) / lon_segments)
|
||||
|
||||
def get_pos(theta, phi):
|
||||
return (
|
||||
radius * np.cos(theta) * np.cos(phi),
|
||||
radius * np.sin(theta),
|
||||
radius * np.cos(theta) * np.sin(phi)
|
||||
)
|
||||
|
||||
# Wierzchołki
|
||||
p1 = get_pos(theta1, phi1)
|
||||
p2 = get_pos(theta2, phi1)
|
||||
p3 = get_pos(theta2, phi2)
|
||||
p4 = get_pos(theta1, phi2)
|
||||
|
||||
# UV mapping (prosty sferyczny)
|
||||
uv1 = (j / lon_segments, i / lat_segments)
|
||||
uv2 = (j / lon_segments, (i + 1) / lat_segments)
|
||||
uv3 = ((j + 1) / lon_segments, (i + 1) / lat_segments)
|
||||
uv4 = ((j + 1) / lon_segments, i / lat_segments)
|
||||
|
||||
# Trójkąty
|
||||
for vtx, uv in zip([p1, p2, p3], [uv1, uv2, uv3]):
|
||||
verts.extend([*vtx, *color, *uv])
|
||||
for vtx, uv in zip([p1, p3, p4], [uv1, uv3, uv4]):
|
||||
verts.extend([*vtx, *color, *uv])
|
||||
return Mesh(verts, basic_shader())
|
||||
|
||||
@staticmethod
|
||||
def capsule(radius=0.25, height=1.0, segments=16, color=(1, 1, 1)):
|
||||
verts = []
|
||||
half = height / 2
|
||||
|
||||
# === Cylinder środkowy ===
|
||||
for j in range(segments):
|
||||
theta1 = 2 * np.pi * (j / segments)
|
||||
theta2 = 2 * np.pi * ((j + 1) / segments)
|
||||
|
||||
x1, z1 = np.cos(theta1), np.sin(theta1)
|
||||
x2, z2 = np.cos(theta2), np.sin(theta2)
|
||||
|
||||
p1 = (radius * x1, -half, radius * z1)
|
||||
p2 = (radius * x1, half, radius * z1)
|
||||
p3 = (radius * x2, half, radius * z2)
|
||||
p4 = (radius * x2, -half, radius * z2)
|
||||
|
||||
uv = [(j / segments, 0.0), (j / segments, 0.5), ((j + 1) / segments, 0.5), ((j + 1) / segments, 0.0)]
|
||||
for vtx, tex in zip([p1, p2, p3], [uv[0], uv[1], uv[2]]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
for vtx, tex in zip([p1, p3, p4], [uv[0], uv[2], uv[3]]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
|
||||
# === Półsfera górna ===
|
||||
for i in range(segments // 2):
|
||||
theta1 = (np.pi / 2) * (i / (segments // 2))
|
||||
theta2 = (np.pi / 2) * ((i + 1) / (segments // 2))
|
||||
for j in range(segments):
|
||||
phi1 = 2 * np.pi * (j / segments)
|
||||
phi2 = 2 * np.pi * ((j + 1) / segments)
|
||||
|
||||
def pos(theta, phi):
|
||||
return (
|
||||
radius * np.cos(theta) * np.cos(phi),
|
||||
radius * np.sin(theta) + half,
|
||||
radius * np.cos(theta) * np.sin(phi)
|
||||
)
|
||||
|
||||
p1 = pos(theta1, phi1)
|
||||
p2 = pos(theta2, phi1)
|
||||
p3 = pos(theta2, phi2)
|
||||
p4 = pos(theta1, phi2)
|
||||
|
||||
uv1 = (j / segments, 0.5 + (i / (segments * 2)))
|
||||
uv2 = (j / segments, 0.5 + ((i + 1) / (segments * 2)))
|
||||
uv3 = ((j + 1) / segments, 0.5 + ((i + 1) / (segments * 2)))
|
||||
uv4 = ((j + 1) / segments, 0.5 + (i / (segments * 2)))
|
||||
|
||||
for vtx, tex in zip([p1, p2, p3], [uv1, uv2, uv3]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
for vtx, tex in zip([p1, p3, p4], [uv1, uv3, uv4]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
|
||||
# === Półsfera dolna ===
|
||||
for i in range(segments // 2):
|
||||
theta1 = (np.pi / 2) * (i / (segments // 2))
|
||||
theta2 = (np.pi / 2) * ((i + 1) / (segments // 2))
|
||||
for j in range(segments):
|
||||
phi1 = 2 * np.pi * (j / segments)
|
||||
phi2 = 2 * np.pi * ((j + 1) / segments)
|
||||
|
||||
def pos(theta, phi):
|
||||
return (
|
||||
radius * np.cos(theta) * np.cos(phi),
|
||||
-radius * np.sin(theta) - half,
|
||||
radius * np.cos(theta) * np.sin(phi)
|
||||
)
|
||||
|
||||
p1 = pos(theta1, phi1)
|
||||
p2 = pos(theta2, phi1)
|
||||
p3 = pos(theta2, phi2)
|
||||
p4 = pos(theta1, phi2)
|
||||
|
||||
uv1 = (j / segments, 0.5 - (i / (segments * 2)))
|
||||
uv2 = (j / segments, 0.5 - ((i + 1) / (segments * 2)))
|
||||
uv3 = ((j + 1) / segments, 0.5 - ((i + 1) / (segments * 2)))
|
||||
uv4 = ((j + 1) / segments, 0.5 - (i / (segments * 2)))
|
||||
|
||||
# UWAGA: zamieniona kolejność rysowania — PRAWIDŁOWY winding
|
||||
for vtx, tex in zip([p1, p3, p2], [uv1, uv3, uv2]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
for vtx, tex in zip([p1, p4, p3], [uv1, uv4, uv3]):
|
||||
verts.extend([*vtx, *color, *tex])
|
||||
|
||||
return Mesh(verts, basic_shader())
|
||||
|
||||
@staticmethod
|
||||
def plane(size=1.0, color=(1, 1, 1)):
|
||||
hs = size / 2
|
||||
positions = [(-hs, 0, -hs), (hs, 0, -hs), (hs, 0, hs), (-hs, 0, hs)]
|
||||
uvs = [(0, 0), (1, 0), (1, 1), (0, 1)]
|
||||
indices = [(0, 1, 2), (0, 2, 3)] # front
|
||||
back_indices = [(2, 1, 0), (3, 2, 0)] # back side (odwrócone)
|
||||
|
||||
verts = []
|
||||
for face in indices + back_indices:
|
||||
for idx in face:
|
||||
verts.extend([*positions[idx], *color, *uvs[idx]])
|
||||
return Mesh(verts, basic_shader())
|
||||
|
||||
@staticmethod
|
||||
def water(
|
||||
size=10.0,
|
||||
resolution=64,
|
||||
wave_speed=0.03,
|
||||
wave_height=0.1,
|
||||
wave_scale=(0.3, 0.4),
|
||||
second_wave=True,
|
||||
color=(0.2, 0.5, 1.0),
|
||||
backface=True
|
||||
):
|
||||
half = size / 2
|
||||
step = size / resolution
|
||||
verts = []
|
||||
|
||||
for z in range(resolution):
|
||||
for x in range(resolution):
|
||||
x0 = -half + x * step
|
||||
x1 = x0 + step
|
||||
z0 = -half + z * step
|
||||
z1 = z0 + step
|
||||
|
||||
u0, u1 = x / resolution, (x + 1) / resolution
|
||||
v0, v1 = z / resolution, (z + 1) / resolution
|
||||
|
||||
pos = [(x0, 0.0, z0), (x1, 0.0, z0), (x1, 0.0, z1), (x0, 0.0, z1)]
|
||||
uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]
|
||||
|
||||
# Indeksy dla frontu i ewentualnie backface
|
||||
indices = [(0, 1, 2), (0, 2, 3)]
|
||||
if backface:
|
||||
indices += [(2, 1, 0), (3, 2, 0)]
|
||||
|
||||
for a, b, c in indices:
|
||||
for i in [a, b, c]:
|
||||
verts.extend([*pos[i], *color, *uv[i]])
|
||||
|
||||
mesh = Mesh(verts, basic_shader())
|
||||
mesh._water_size = size
|
||||
mesh._water_res = resolution
|
||||
mesh._water_time = 0.0
|
||||
mesh._wave_speed = wave_speed
|
||||
mesh._wave_height = wave_height
|
||||
mesh._wave_scale = wave_scale
|
||||
mesh._second_wave = second_wave
|
||||
mesh._water_verts = np.array(verts, dtype=np.float32).reshape(-1, 8)
|
||||
|
||||
# Wysokości bazowe (y)
|
||||
mesh._water_initial_y = mesh._water_verts[:, 1].copy()
|
||||
|
||||
# Dynamiczny buffer
|
||||
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo)
|
||||
glBufferData(GL_ARRAY_BUFFER, mesh._water_verts.nbytes, mesh._water_verts.flatten(), GL_DYNAMIC_DRAW)
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
||||
|
||||
def update_water():
|
||||
mesh._water_time += mesh._wave_speed
|
||||
verts = mesh._water_verts
|
||||
X = verts[:, 0]
|
||||
Z = verts[:, 2]
|
||||
|
||||
# Fale
|
||||
verts[:, 1] = (
|
||||
np.sin(X * mesh._wave_scale[0] + mesh._water_time) +
|
||||
(np.cos(Z * mesh._wave_scale[1] + mesh._water_time) if mesh._second_wave else 0.0)
|
||||
) * (mesh._wave_height * 0.5)
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo)
|
||||
glBufferSubData(GL_ARRAY_BUFFER, 0, verts.nbytes, verts.flatten())
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
||||
|
||||
mesh.update_water = update_water
|
||||
return mesh
|
||||
|
||||
def update_water(self):
|
||||
if not hasattr(self, '_original_vertices'):
|
||||
return
|
||||
|
||||
verts = self._original_vertices.copy()
|
||||
t = time.time() - self._start_time
|
||||
|
||||
for i in range(0, len(verts), 8):
|
||||
x = verts[i]
|
||||
z = verts[i + 2]
|
||||
verts[i + 1] = math.sin(x * 0.5 + t) * 0.2 + math.cos(z * 0.5 + t) * 0.2
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
|
||||
glBufferSubData(GL_ARRAY_BUFFER, 0, verts.nbytes, verts)
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
||||
Reference in New Issue
Block a user