Compare commits
No commits in common. "main" and "gl_test" have entirely different histories.
5 changed files with 140 additions and 153 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
|
@ -60,5 +60,10 @@ void main()
|
|||
outColor *= texture(occlusionMap, (vec2(ao_square, 0)+fract(fragTileTexCoord))/vec2(256, 1));
|
||||
|
||||
outColor.a = 1;
|
||||
|
||||
//uint bit = uint(fragTileTexCoord * 32);
|
||||
//outColor.g = (((uint(quadWidth) >> bit) & uint(1)) == uint(1)) ?
|
||||
// ((bit % uint(2) == uint(0)) ? 1.0 : 0.8):
|
||||
// ((bit % uint(2) == uint(0)) ? 0.0 : 0.2);
|
||||
}
|
||||
|
||||
|
|
|
|||
57
src/main.zig
57
src/main.zig
|
|
@ -1,5 +1,4 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const raylib_helper = @import("lib_helpers/raylib_helper.zig");
|
||||
const raylib = raylib_helper.raylib;
|
||||
|
|
@ -10,6 +9,7 @@ const chunks = @import("world/chunk.zig");
|
|||
|
||||
const TILE_TEXTURE_RESOLUTION = 16;
|
||||
|
||||
const benchmark_chunk_meshing = false;
|
||||
const debug = true;
|
||||
|
||||
pub fn drawCameraPosition(camera: raylib.Camera3D, x: i32, y: i32) !void {
|
||||
|
|
@ -29,26 +29,6 @@ pub fn moveCamera(camera: *raylib.Camera3D, vec: raylib.Vector3) void {
|
|||
camera.target = v3.add(camera.target, vec);
|
||||
}
|
||||
|
||||
pub fn createDefaultChunk(a7r: Allocator) !chunks.Chunk {
|
||||
var chunk = try chunks.Chunk.init(a7r);
|
||||
|
||||
const height_generator = znoise.FnlGenerator{ .seed = 413445 };
|
||||
const tile_type_generator = znoise.FnlGenerator{ .seed = 4435, .frequency = 0.1 };
|
||||
for (0..32) |raw_x| for (0..32) |raw_y| for (0..32) |raw_z| {
|
||||
const x: u5 = @intCast(raw_x);
|
||||
const y: u5 = @intCast(raw_y);
|
||||
const z: u5 = @intCast(raw_z);
|
||||
const xf: f32 = @floatFromInt(raw_x);
|
||||
const yf: f32 = @floatFromInt(raw_y);
|
||||
const zf: f32 = @floatFromInt(raw_z);
|
||||
const tile_type: u32 = if (tile_type_generator.noise3(xf, yf, zf) > 0) 1 else 2;
|
||||
const height: f32 = (height_generator.noise2(xf, zf) + 1) * 16;
|
||||
if (height >= yf) chunk.setTile(x, y, z, tile_type);
|
||||
// if((xf-16)*(xf-16)+(yf-16)*(yf-16)+(zf-16)*(zf-16) < 16*16) chunk.setTile(x, y, z, tile_type);
|
||||
};
|
||||
return chunk;
|
||||
}
|
||||
|
||||
pub fn main() !void {
|
||||
if (!debug) raylib.SetTraceLogLevel(raylib.LOG_ERROR);
|
||||
|
||||
|
|
@ -93,9 +73,38 @@ pub fn main() !void {
|
|||
defer raylib.UnloadShader(shader);
|
||||
raylib.SetShaderValue(shader, raylib.GetShaderLocation(shader, "textureTiling"), &.{ @as(f32, @floatFromInt(tile_columns)), @as(f32, @floatFromInt(tile_rows)) }, raylib.SHADER_UNIFORM_VEC2);
|
||||
|
||||
var chunk = try createDefaultChunk(a7r);
|
||||
var chunk = try chunks.Chunk.init(a7r);
|
||||
defer chunk.deinit();
|
||||
|
||||
// const height_generator = znoise.FnlGenerator{ .seed = 413445 };
|
||||
const tile_type_generator = znoise.FnlGenerator{ .seed = 4435, .frequency = 0.1 };
|
||||
for (0..32) |raw_x| for (0..32) |raw_y| for (0..32) |raw_z| {
|
||||
const x: u5 = @intCast(raw_x);
|
||||
const y: u5 = @intCast(raw_y);
|
||||
const z: u5 = @intCast(raw_z);
|
||||
const xf: f32 = @floatFromInt(raw_x);
|
||||
const yf: f32 = @floatFromInt(raw_y);
|
||||
const zf: f32 = @floatFromInt(raw_z);
|
||||
const tile_type: u32 = if (tile_type_generator.noise3(xf, yf, zf) > 0) 2 else 2;
|
||||
// const height: f32 = (height_generator.noise2(xf, zf) + 1) * 16 + @as(f32, if(x > 24) 4.0 else 0.0) + @as(f32, if(z < 8) 4.0 else 0.0);
|
||||
// if (height >= yf) chunk.setTile(x, y, z, tile_type);
|
||||
if((xf-16)*(xf-16)+(yf-16)*(yf-16)+(zf-16)*(zf-16) < 16*16) chunk.setTile(x, y, z, tile_type);
|
||||
};
|
||||
|
||||
if (benchmark_chunk_meshing) {
|
||||
var tmp: u64 = 0;
|
||||
for (0..500) |_| {
|
||||
const start = try std.time.Instant.now();
|
||||
const model = raylib.LoadChunkModelFromMesh(try chunk.createMesh(tile_rows, tile_columns));
|
||||
defer raylib.UnloadChunkModel(model);
|
||||
const end = try std.time.Instant.now();
|
||||
tmp += end.since(start);
|
||||
}
|
||||
std.debug.print("\nchunk meshing time: {d:.3}ms\n\n", .{
|
||||
@as(f64, @floatFromInt(tmp)) / std.time.ns_per_ms / 500,
|
||||
});
|
||||
}
|
||||
|
||||
const model = raylib.LoadChunkModelFromMesh(try chunk.createMesh(tile_rows, tile_columns));
|
||||
defer raylib.UnloadChunkModel(model);
|
||||
model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE].texture = texture;
|
||||
|
|
@ -104,6 +113,10 @@ pub fn main() !void {
|
|||
model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1] = raylib.GetShaderLocation(shader, "occlusionMap");
|
||||
raylib.SetShaderValueTexture(shader, model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1], ambient_occlusion_texture); model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE+1].texture = ambient_occlusion_texture;
|
||||
|
||||
// for (0..32) |i|{
|
||||
// std.debug.print("shader loc {}: {}\n", .{i, shader.locs[i]});
|
||||
// }
|
||||
|
||||
while (!raylib.WindowShouldClose()) {
|
||||
raylib.ClearBackground(raylib.BLACK);
|
||||
|
||||
|
|
|
|||
|
|
@ -198,100 +198,11 @@ pub const Chunk = struct {
|
|||
}
|
||||
return raw_quad;
|
||||
}
|
||||
|
||||
fn packMeshFromRawQuads(raw_quads: std.ArrayList(RawQuad), tile_columns: u32, tile_rows: u32) raylib.ChunkMesh {
|
||||
// Create OpenGL buffers
|
||||
const triangle_count: i32 = @as(i32, @intCast(raw_quads.items.len)) * 2;
|
||||
|
||||
const arr_size: u32 = @as(u32, @intCast(triangle_count)) * 3 * @sizeOf(f32);
|
||||
|
||||
const vertices: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 3)));
|
||||
const texcoords: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 2)));
|
||||
const tiletexcoords: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 2)));
|
||||
const normals: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 3)));
|
||||
const metadata1_packed: [*]u32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 4)));
|
||||
const occlusion_sides: [*]u32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 4)));
|
||||
|
||||
for (raw_quads.items, 0..) |raw_quad, i| {
|
||||
if (raw_quad.tile <= 0) continue; // air tile, no texture
|
||||
const tile = raw_quad.tile;
|
||||
|
||||
// Set normals for the quads (same as the triangles.)
|
||||
for (0..6) |j| {
|
||||
normals[18 * i + 3 * j + 0] = raw_quad.normal.x;
|
||||
normals[18 * i + 3 * j + 1] = raw_quad.normal.y;
|
||||
normals[18 * i + 3 * j + 2] = raw_quad.normal.z;
|
||||
}
|
||||
|
||||
// Find UV coordinates of corresponding tiles.
|
||||
const left_uv = @as(f32, @floatFromInt(tile % tile_columns)) / @as(f32, @floatFromInt(tile_columns));
|
||||
const right_uv = @as(f32, @floatFromInt(tile % tile_columns + 1)) / @as(f32, @floatFromInt(tile_columns));
|
||||
const top_uv = @as(f32, @floatFromInt(tile / tile_columns)) / @as(f32, @floatFromInt(tile_rows));
|
||||
const bottom_uv = @as(f32, @floatFromInt(tile / tile_columns + 1)) / @as(f32, @floatFromInt(tile_rows));
|
||||
|
||||
// Unwrap raw quads vertex coordinates and UV coordinates into OpenGL buffers.
|
||||
const vertex_corners = .{ raw_quad.top_left, raw_quad.bottom_left, raw_quad.top_right, raw_quad.bottom_right, raw_quad.top_right, raw_quad.bottom_left };
|
||||
const texcoords_x = .{ left_uv, left_uv, right_uv, right_uv, right_uv, left_uv };
|
||||
const texcoords_y = .{ top_uv, bottom_uv } ** 3;
|
||||
const tiletexcoords_x = if(raw_quad.flip_x)
|
||||
.{raw_quad.width, raw_quad.width, 0.0, 0.0, 0.0, raw_quad.width} else
|
||||
.{ 0.0, 0.0, raw_quad.width, raw_quad.width, raw_quad.width, 0.0 };
|
||||
const tiletexcoords_y = if(raw_quad.flip_y) .{ raw_quad.height, 0.0 } ** 3 else .{ 0.0, raw_quad.height } ** 3;
|
||||
|
||||
inline for (0..6) |corner_id| {
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 0] = vertex_corners[corner_id].x;
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 1] = vertex_corners[corner_id].y;
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 2] = vertex_corners[corner_id].z;
|
||||
texcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 0] = texcoords_x[corner_id];
|
||||
texcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 1] = texcoords_y[corner_id];
|
||||
tiletexcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 0] = tiletexcoords_x[corner_id];
|
||||
tiletexcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 1] = tiletexcoords_y[corner_id];
|
||||
}
|
||||
|
||||
// Store metadata into OpenGL buffers.
|
||||
for (0..6) |j| {
|
||||
const metadata1 = Metadata1{
|
||||
.top_left_obscured = raw_quad.top_left_obscured,
|
||||
.top_right_obscured = raw_quad.top_right_obscured,
|
||||
.bottom_left_obscured = raw_quad.bottom_left_obscured,
|
||||
.bottom_right_obscured = raw_quad.bottom_right_obscured,
|
||||
.quad_height = @intFromFloat(raw_quad.height),
|
||||
.quad_width = @intFromFloat(raw_quad.width),
|
||||
};
|
||||
const metadata1_baked: [4]u32 = @bitCast(metadata1);
|
||||
for (0..4) |k| {
|
||||
metadata1_packed[24 * i + 4 * j + k] = metadata1_baked[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Store ambient occlusion sides into OpenGL buffers.
|
||||
for (0..6) |j| {
|
||||
occlusion_sides[24 * i + 4 * j + 0] = raw_quad.left_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 1] = raw_quad.right_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 2] = raw_quad.top_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 3] = raw_quad.bottom_obscuring_pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Create mesh using the buffers.
|
||||
return raylib.ChunkMesh{
|
||||
.triangleCount = triangle_count,
|
||||
.vertexCount = triangle_count * 3,
|
||||
|
||||
.vertices = vertices,
|
||||
.texcoords = texcoords,
|
||||
.tiletexcoords = tiletexcoords,
|
||||
.normals = normals,
|
||||
.metadata1 = metadata1_packed,
|
||||
.occlusion_sides = occlusion_sides,
|
||||
|
||||
.vaoId = 0,
|
||||
.vboId = null,
|
||||
};
|
||||
}
|
||||
|
||||
fn scanForRawQuads(chunk: Chunk) !std.ArrayList(RawQuad) {
|
||||
// Create mesh of a chunk. tile_rows and tile_columns are the dimensions of the tiles.png file, in terms of individual tile textures.
|
||||
pub fn createMesh(chunk: Chunk, tile_rows: u32, tile_columns: u32) !raylib.ChunkMesh {
|
||||
var raw_quads = try std.ArrayList(RawQuad).initCapacity(chunk.a7r, 4096);
|
||||
defer raw_quads.deinit();
|
||||
|
||||
// Begin scanning the chunk for tile surfaces to make raw quads.
|
||||
inline for (0..3) |dimension| { // Iterate over the 3 dimensions, X, Y and Z.
|
||||
|
|
@ -379,15 +290,95 @@ pub const Chunk = struct {
|
|||
}
|
||||
}
|
||||
}
|
||||
return raw_quads;
|
||||
}
|
||||
|
||||
// Create mesh of a chunk. tile_rows and tile_columns are the dimensions of the tiles.png file, in terms of individual tile textures.
|
||||
pub fn createMesh(chunk: Chunk, tile_rows: u32, tile_columns: u32) !raylib.ChunkMesh {
|
||||
var raw_quads = try scanForRawQuads(chunk);
|
||||
defer raw_quads.deinit();
|
||||
// Create OpenGL buffers
|
||||
const triangle_count: i32 = @as(i32, @intCast(raw_quads.items.len)) * 2;
|
||||
|
||||
var mesh = packMeshFromRawQuads(raw_quads, tile_columns, tile_rows);
|
||||
const arr_size: u32 = @as(u32, @intCast(triangle_count)) * 3 * @sizeOf(f32);
|
||||
|
||||
const vertices: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 3)));
|
||||
const texcoords: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 2)));
|
||||
const tiletexcoords: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 2)));
|
||||
const normals: [*]f32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 3)));
|
||||
const metadata1_packed: [*]u32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 4)));
|
||||
const occlusion_sides: [*]u32 = @ptrCast(@alignCast(raylib.MemAlloc(arr_size * 4)));
|
||||
|
||||
for (raw_quads.items, 0..) |raw_quad, i| {
|
||||
if (raw_quad.tile <= 0) continue; // air tile, no texture
|
||||
const tile = raw_quad.tile;
|
||||
|
||||
// Set normals for the quads (same as the triangles.)
|
||||
for (0..6) |j| {
|
||||
normals[18 * i + 3 * j + 0] = raw_quad.normal.x;
|
||||
normals[18 * i + 3 * j + 1] = raw_quad.normal.y;
|
||||
normals[18 * i + 3 * j + 2] = raw_quad.normal.z;
|
||||
}
|
||||
|
||||
// Find UV coordinates of corresponding tiles.
|
||||
const left_uv = @as(f32, @floatFromInt(tile % tile_columns)) / @as(f32, @floatFromInt(tile_columns));
|
||||
const right_uv = @as(f32, @floatFromInt(tile % tile_columns + 1)) / @as(f32, @floatFromInt(tile_columns));
|
||||
const top_uv = @as(f32, @floatFromInt(tile / tile_columns)) / @as(f32, @floatFromInt(tile_rows));
|
||||
const bottom_uv = @as(f32, @floatFromInt(tile / tile_columns + 1)) / @as(f32, @floatFromInt(tile_rows));
|
||||
|
||||
// Unwrap raw quads vertex coordinates and UV coordinates into OpenGL buffers.
|
||||
const vertex_corners = .{ raw_quad.top_left, raw_quad.bottom_left, raw_quad.top_right, raw_quad.bottom_right, raw_quad.top_right, raw_quad.bottom_left };
|
||||
const texcoords_x = .{ left_uv, left_uv, right_uv, right_uv, right_uv, left_uv };
|
||||
const texcoords_y = .{ top_uv, bottom_uv } ** 3;
|
||||
const tiletexcoords_x = if(raw_quad.flip_x)
|
||||
.{raw_quad.width, raw_quad.width, 0.0, 0.0, 0.0, raw_quad.width} else
|
||||
.{ 0.0, 0.0, raw_quad.width, raw_quad.width, raw_quad.width, 0.0 };
|
||||
const tiletexcoords_y = if(raw_quad.flip_y) .{ raw_quad.height, 0.0 } ** 3 else .{ 0.0, raw_quad.height } ** 3;
|
||||
|
||||
inline for (0..6) |corner_id| {
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 0] = vertex_corners[corner_id].x;
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 1] = vertex_corners[corner_id].y;
|
||||
vertices[VERTICES_BLOCK_SIZE * i + corner_id * 3 + 2] = vertex_corners[corner_id].z;
|
||||
texcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 0] = texcoords_x[corner_id];
|
||||
texcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 1] = texcoords_y[corner_id];
|
||||
tiletexcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 0] = tiletexcoords_x[corner_id];
|
||||
tiletexcoords[TEXCOORDS_BLOCK_SIZE * i + corner_id * 2 + 1] = tiletexcoords_y[corner_id];
|
||||
}
|
||||
|
||||
// Store metadata into OpenGL buffers.
|
||||
for (0..6) |j| {
|
||||
const metadata1 = Metadata1{
|
||||
.top_left_obscured = raw_quad.top_left_obscured,
|
||||
.top_right_obscured = raw_quad.top_right_obscured,
|
||||
.bottom_left_obscured = raw_quad.bottom_left_obscured,
|
||||
.bottom_right_obscured = raw_quad.bottom_right_obscured,
|
||||
.quad_height = @intFromFloat(raw_quad.height),
|
||||
.quad_width = @intFromFloat(raw_quad.width),
|
||||
};
|
||||
const metadata1_baked: [4]u32 = @bitCast(metadata1);
|
||||
for (0..4) |k| {
|
||||
metadata1_packed[24 * i + 4 * j + k] = metadata1_baked[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Store ambient occlusion sides into OpenGL buffers.
|
||||
for (0..6) |j| {
|
||||
occlusion_sides[24 * i + 4 * j + 0] = raw_quad.left_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 1] = raw_quad.right_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 2] = raw_quad.top_obscuring_pattern;
|
||||
occlusion_sides[24 * i + 4 * j + 3] = raw_quad.bottom_obscuring_pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Create mesh using the buffers.
|
||||
var mesh = raylib.ChunkMesh{
|
||||
.triangleCount = triangle_count,
|
||||
.vertexCount = triangle_count * 3,
|
||||
|
||||
.vertices = vertices,
|
||||
.texcoords = texcoords,
|
||||
.tiletexcoords = tiletexcoords,
|
||||
.normals = normals,
|
||||
.metadata1 = metadata1_packed,
|
||||
.occlusion_sides = occlusion_sides,
|
||||
|
||||
.vaoId = 0,
|
||||
.vboId = null,
|
||||
};
|
||||
|
||||
raylib.UploadChunkMesh(@ptrCast(&mesh), false);
|
||||
|
||||
|
|
|
|||
42
todo.md
42
todo.md
|
|
@ -1,33 +1,11 @@
|
|||
# world generation
|
||||
1. world state "object" which will keep track of chunks to generate, to remove, to keep, those that are currently being generated in a thread pool.
|
||||
2. parallelised creeping chunk generation in a radius.
|
||||
3. removing of chunks when they go outside the radius.
|
||||
4. interlinked chunks; each chunk should have a ptr to the 26 neighbor chunks or null.
|
||||
5. world state linking and unlinking chunks automatically.
|
||||
6. ambient occlusion spanning between chunks.
|
||||
7. chunk loading, unloading, saving/loading from files; world state keeping track of chunks loaded and unloaded as well
|
||||
8. generation of (LOD) simplified chunk models
|
||||
9. generation of simplified chunks
|
||||
10. separate LOD radiuses for different LOD levels
|
||||
11. partial chunk loading and unloading.
|
||||
12. block change handling: recalculate only the relevant quads
|
||||
# current tasks
|
||||
yoink implementation of mesh into meshes.zig so i could modify it to add ambient occlusion vars
|
||||
implement ambient occlusion either
|
||||
- via passing extra info to shader (can use 132 bits to indicate what tiles around the quad are obsuring light. need to pass as an extra variable)
|
||||
implement animations (smooth or minecraft like), random orientation/mirroring, metallicity (shine on edges)
|
||||
implement second layer of textures on top of first one for more details
|
||||
limit vertical camera rotations
|
||||
|
||||
# misc graphics
|
||||
1. implement possibilities for 6 different surfaces for a tile, e.g a branch tile should have 4 bark surfaces and 2 inner surfaces
|
||||
2. implement random rotation and reflection of surfaces
|
||||
3. implement fixed rotation and reflection of surfaces
|
||||
4. implement custom models, e.g a tall grass tile is a model with two planes crossing in the middle (see: minecraft). implement optional greedy meshing for such models; e.g tall grass textures could fuse together diagonally and grass could have 2 layers who could mesh as well.
|
||||
5. implement metallicity (e.g block of iron) (?, discuss)
|
||||
6. implement random shininess (e.g rough diamond block, would sparkle as you move across, see purple rocks in PEAK's Alpine biome)
|
||||
7. implement emissive textures (e.g a computer screen, see minecraft's enderman/spider eyes and sculk blocks)
|
||||
8. implement bloom effect (see gregtech's glowing EBF's)
|
||||
|
||||
# directional sun/moonlight
|
||||
1. discuss implementation
|
||||
|
||||
# directional central lightning, e.g torches
|
||||
1. discuss implementation
|
||||
|
||||
# tooling
|
||||
1. benchmarking step in build.zig: benchmark chunk meshing, generation, and similar tasks which will need to be performed at scale
|
||||
2. texturepack compiler: something that would compile minecraft-style texturepacks into a format that could be more suited for in game usage.
|
||||
# future tasks
|
||||
implement chunk meshing cache to reduce delay on block placement
|
||||
investigate binary/SIMD meshing for performance
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue