import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { block_light_emission, block_light_opacity, BlockRegistry, EverythingRegistry, RENDER_LAYERS, RenderLayer, } from "$/common/everything_registry.ts"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts"; import { AssetManager } from "../assets.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts"; import { worldgen_mods } from "../mods.ts"; import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts"; import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts"; import { crosses_planes } from "../workers/translucent_sort.ts"; import { Camera } from "../camera.ts"; import type { Entity } from "../entity/entity.ts"; export interface Block { id: string; x: number; y: number; z: number; } export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE }; export interface Chunk { x: number; z: number; blocks: Uint32Array; generated: boolean; dirty: boolean; // bumped on every mesh request so late results from older requests get ignored mesh_version: number; meshes: Partial>; // only for translucent meshes that have to be sorted again as the camera moves translucent_sort?: TranslucentSort; } export interface ChunkMesh { vertex_buffer: GPUBuffer; quad_count: number; // the translucent layer's quads sorted back to front, the others are drawn in order index_buffer?: GPUBuffer; } interface TranslucentSort { // the mesh_version of the mesh these are for mesh_version: number; centers: Float32Array; planes: [Float32Array, Float32Array, Float32Array]; // where the camera was for the last sort that was requested camera: number[]; // sorts come back out of order, older ones than what's shown get skipped requested: number; applied: number; } const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS; export { chunk_key }; // the block being looked at and which face of it export interface BlockHitResult { x: number; y: number; z: number; block: number; face: Faces; } const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; // light spreads diagonally too, so meshing and lighting need all 8 const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const; // what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it export class ClientLevel { image: Texture = AssetManager.instance.get("bworld:textures"); chunks = new Map(); second_timer = 0; tick_timer = 0; seed: string; // blocks players changed from the generated terrain, per chunk, so they survive reloading chunks changes = new Map>(); workers: ChunkWorkerPool; #blocks = EverythingRegistry.get_registry("blocks"); // chunks being generated by a worker, by chunk key pending_generation = new Map(); // every entity this client knows about, the local player included, by id entities = new Map(); constructor(seed = "seed") { this.seed = seed; this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message)); const blocks_registry = EverythingRegistry.get_registry("blocks"); const block_ids: Record = {}; blocks_registry.forEach((block, nid) => block_ids[block.id] = nid); // sent once instead of with every mesh request this.workers.broadcast({ type: "init", blocks_registry: strip_functions(blocks_registry), block_ids, textures_info: AssetManager.instance.get("bworld:textures_info"), image: { width: this.image.width, height: this.image.height }, worldgen_scripts: worldgen_mods.scripts, ores: worldgen_mods.ores, }); } add_entity(entity: Entity) { this.entities.set(entity.id, entity); } remove_entity(id: string) { this.entities.delete(id); } tick(delta: number) { for (const entity of this.entities.values()) { entity.tick(delta); } } // generates the chunks around a position and forgets the ones too far away. one extra ring past the render // distance gets generated so the edge has neighbors to mesh against update_loaded_chunks(x: number, z: number, render_distance: number) { const center_x = Math.floor(x / CHUNK_SIZE); const center_z = Math.floor(z / CHUNK_SIZE); const load_distance = render_distance + 1; const out_of_range = (cx: number, cz: number) => Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance; // collect first, deleting from the map while iterating it skips entries const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z)); for (const chunk of to_unload) { this.unload_chunk(chunk.x, chunk.z); } for (const pending of [...this.pending_generation.values()]) { if (out_of_range(pending.x, pending.z)) { this.cancel_chunk_request(pending.x, pending.z); } } // dont queue up the whole area at once, so walking somewhere new gets the close chunks first const max_in_flight = this.workers.size * 2; if (this.pending_generation.size >= max_in_flight) { return; } const missing: { x: number; z: number; distance: number }[] = []; for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) { for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) { if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) { const dx = cx - center_x; const dz = cz - center_z; missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz }); } } } missing.sort((a, b) => a.distance - b.distance); for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) { this.request_chunk(chunk.x, chunk.z); } } dispose() { this.workers.terminate(); for (const chunk of this.chunks.values()) { this.delete_chunk_mesh(chunk); } this.chunks.clear(); this.pending_generation.clear(); } add_chunk(x: number, z: number, blocks: Uint32Array = new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT)) { const chunk: Chunk = { x, z, blocks, dirty: true, generated: false, mesh_version: 0, meshes: {}, }; this.chunks.set(chunk_key(x, z), chunk); return chunk; } get_chunk(x: number, z: number) { return this.chunks.get(chunk_key(x, z)); } // state is the block's state bits, its defaults when not given add_block(block: Block, state?: number) { const block_chunk_x = Math.floor(block.x / CHUNK_SIZE); const block_chunk_z = Math.floor(block.z / CHUNK_SIZE); let chunk = this.get_chunk(block_chunk_x, block_chunk_z); if (!chunk) { chunk = this.add_chunk(block_chunk_x, block_chunk_z); } const [nid, info] = EverythingRegistry.get_full("blocks", block.id)!; const lx = block.x - block_chunk_x * CHUNK_SIZE; const lz = block.z - block_chunk_z * CHUNK_SIZE; const ly = block.y; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const old_nid = chunk.blocks[index] & ID_MASK; chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state); chunk.dirty = true; this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, nid); } // the id with its state bits, VOID outside loaded chunks get_block_value(x: number, y: number, z: number) { const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); const chunk = this.get_chunk(chunk_x, chunk_z); if (!chunk) { return VOID; } return chunk.blocks[y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE)] ?? AIR; } get_block(x: number, y: number, z: number) { const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); const chunk = this.get_chunk(chunk_x, chunk_z); if (!chunk) { return VOID; } const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; const ly = y; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; return chunk.blocks[index] & ID_MASK; } // only changes what this client shows, drops and everything else happen on the server break_block(x: number, y: number, z: number) { const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_z = Math.floor(z / CHUNK_SIZE); const chunk = this.get_chunk(block_chunk_x, block_chunk_z); if (!chunk) { return; } const lx = x - block_chunk_x * CHUNK_SIZE; const lz = z - block_chunk_z * CHUNK_SIZE; const ly = y; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const old_nid = chunk.blocks[index] & ID_MASK; chunk.blocks[index] = AIR; chunk.dirty = true; this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, AIR); } // a block that lets through or gives off a different amount of light changes the light up to 15 blocks // away, so in every neighbor. otherwise only a block on a chunk's edge matters, for its neighbor's faces #mark_neighbors_dirty( block_chunk_x: number, block_chunk_z: number, lx: number, lz: number, old_nid: number, new_nid: number, ) { const old_block = this.#blocks[old_nid]; const new_block = this.#blocks[new_nid]; if ( block_light_opacity(old_block) !== block_light_opacity(new_block) || block_light_emission(old_block) !== block_light_emission(new_block) ) { for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) { const n = this.get_chunk(block_chunk_x + dx, block_chunk_z + dz); if (n) { n.dirty = true; } } return; } if (lx === 0) { const n = this.get_chunk(block_chunk_x - 1, block_chunk_z); if (n) { n.dirty = true; } } else if (lx === CHUNK_SIZE - 1) { const n = this.get_chunk(block_chunk_x + 1, block_chunk_z); if (n) { n.dirty = true; } } if (lz === 0) { const n = this.get_chunk(block_chunk_x, block_chunk_z - 1); if (n) { n.dirty = true; } } else if (lz === CHUNK_SIZE - 1) { const n = this.get_chunk(block_chunk_x, block_chunk_z + 1); if (n) { n.dirty = true; } } } index_to_xyz(index: number) { const y = Math.floor(index / CHUNK_AREA); const rem = index % CHUNK_AREA; const z = Math.floor(rem / CHUNK_SIZE); const x = rem % CHUNK_SIZE; return [x, y, z]; } record_change(x: number, y: number, z: number, id: string, state = 0) { const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`; let chunk_changes = this.changes.get(chunk_key); if (!chunk_changes) { chunk_changes = new Map(); this.changes.set(chunk_key, chunk_changes); } chunk_changes.set(`${x},${y},${z}`, [x, y, z, id, state]); } // set a block the server told us about apply_change(x: number, y: number, z: number, id: string, state = 0) { const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)); if (!chunk || !chunk.generated) { return; } const current = this.get_block(x, y, z); if (id === AIR_ID) { if (current !== AIR) { this.break_block(x, y, z); } return; } const nid = EverythingRegistry.get_id("blocks", id); if (nid === undefined || block_value(nid, state) === this.get_block_value(x, y, z)) { return; } this.add_block({ x, y, z, id }, state); } apply_chunk_changes(cx: number, cz: number) { for (const [x, y, z, id, state] of this.changes.get(`${cx},${cz}`)?.values() ?? []) { this.apply_change(x, y, z, id, state); } } is_generated(cx: number, cz: number) { return this.get_chunk(cx, cz)?.generated ?? false; } is_generating(cx: number, cz: number) { return this.pending_generation.has(chunk_key(cx, cz)); } // generation happens in a worker, the chunk shows up in #on_generated request_chunk(cx: number, cz: number) { const key = chunk_key(cx, cz); if (this.pending_generation.has(key) || this.chunks.get(key)?.generated) { return; } this.pending_generation.set(key, { x: cx, z: cz }); this.workers.post({ type: "generate", chunk_x: cx, chunk_z: cz, seed: this.seed }); } cancel_chunk_request(cx: number, cz: number) { this.pending_generation.delete(chunk_key(cx, cz)); } unload_chunk(cx: number, cz: number) { const key = chunk_key(cx, cz); const chunk = this.chunks.get(key); if (!chunk) { return; } this.delete_chunk_mesh(chunk); this.chunks.delete(key); } // a chunk is only meshed once all its neighbors exist, otherwise its border faces and light would be // wrong and it would have to be meshed again as each neighbor loads can_mesh(chunk: Chunk) { if (!chunk.generated) { return false; } for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) { if (!this.is_generated(chunk.x + dx, chunk.z + dz)) { return false; } } return true; } // sends every dirty chunk that can be meshed to the workers request_meshes(camera: Camera) { for (const chunk of this.chunks.values()) { if (!chunk.dirty || !this.can_mesh(chunk)) { continue; } chunk.dirty = false; chunk.mesh_version += 1; // copies, the worker lights the whole 3x3 area const chunks: (Uint32Array | null)[] = []; for (let dz = -1; dz <= 1; dz++) { for (let dx = -1; dx <= 1; dx++) { chunks.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks.slice() ?? null); } } this.workers.post({ type: "mesh", chunk_x: chunk.x, chunk_z: chunk.z, version: chunk.mesh_version, chunks, camera: [camera.x, camera.y, camera.z], }, chunks.filter((blocks) => blocks !== null).map((blocks) => blocks.buffer)); } } // like sodium, a chunk's translucent quads only change order when the camera crosses one of the // planes they lie on, so that's the only time they get sorted again update_translucent_sorting(camera: Camera) { const position = [camera.x, camera.y, camera.z]; for (const chunk of this.chunks.values()) { const sort = chunk.translucent_sort; if (!sort || !crosses_planes(sort.planes, sort.camera, position)) { continue; } sort.camera = position; sort.requested += 1; this.workers.post({ type: "sort", chunk_x: chunk.x, chunk_z: chunk.z, version: sort.mesh_version, sort_version: sort.requested, centers: sort.centers, camera: position, }); } } #on_worker_message(message: FromChunkWorker) { if (message.type === "generated") { this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills); } else if (message.type === "meshed") { this.#on_meshed(message); } else { this.#on_sorted(message); } } #on_generated(cx: number, cz: number, blocks: Uint32Array, spills: Int32Array) { const key = chunk_key(cx, cz); // unloaded or cancelled while the worker was busy if (!this.pending_generation.delete(key)) { return; } let chunk = this.chunks.get(key); if (chunk) { // a placeholder made by a neighbor's tree, keep its blocks where generation left air const existing = chunk.blocks; for (let i = 0; i < blocks.length; i++) { if (blocks[i] !== AIR) { existing[i] = blocks[i]; } } } else { chunk = this.add_chunk(cx, cz, blocks); } chunk.generated = true; chunk.dirty = true; for (let i = 0; i < spills.length; i += 4) { this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]); } for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) { const neighbor = this.get_chunk(cx + dx, cz + dz); if (neighbor && neighbor.generated) { neighbor.dirty = true; } } // trees spill into neighboring chunks, so their changes need reapplying too for (let dx = -1; dx <= 1; dx++) { for (let dz = -1; dz <= 1; dz++) { this.apply_chunk_changes(cx + dx, cz + dz); } } } #on_meshed(message: Extract) { const chunk = this.get_chunk(message.chunk_x, message.chunk_z); // unloaded, or remeshed again since this was requested if (!chunk || chunk.mesh_version !== message.version) { return; } this.delete_chunk_mesh(chunk); for (const layer of RENDER_LAYERS) { const { vertices, quad_count } = message[layer]; if (quad_count === 0) { continue; } chunk.meshes[layer] = { vertex_buffer: create_vertex_buffer(vertices.subarray(0, quad_count * FLOATS_PER_QUAD)), quad_count, }; } const translucent = message.translucent; if (translucent.quad_count === 0) { return; } chunk.meshes.translucent!.index_buffer = create_index_buffer(translucent.indices); if (translucent.sort_type === "dynamic") { chunk.translucent_sort = { mesh_version: message.version, centers: translucent.centers, planes: translucent.planes, camera: message.camera, requested: 0, applied: 0, }; } } #on_sorted(message: Extract) { const chunk = this.get_chunk(message.chunk_x, message.chunk_z); const sort = chunk?.translucent_sort; const mesh = chunk?.meshes.translucent; // remeshed since, or a newer sort already came back if (!sort || !mesh || sort.mesh_version !== message.version || message.sort_version <= sort.applied) { return; } sort.applied = message.sort_version; if (mesh.index_buffer) { destroy_buffer(mesh.index_buffer); } mesh.index_buffer = create_index_buffer(message.indices); } // a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first. // the server builds chunks the same way (server/game/world.ts) #set_block_raw(x: number, y: number, z: number, nid: number) { if (y < 0 || y >= CHUNK_HEIGHT) { return; } const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE); const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z); const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; if (chunk.blocks[index] === AIR) { chunk.blocks[index] = nid; chunk.dirty = true; this.#mark_neighbors_dirty(chunk_x, chunk_z, lx, lz, AIR, nid & ID_MASK); } } delete_chunk_mesh(chunk: Chunk) { for (const mesh of Object.values(chunk.meshes)) { destroy_buffer(mesh.vertex_buffer); if (mesh.index_buffer) { destroy_buffer(mesh.index_buffer); } } chunk.meshes = {}; chunk.translucent_sort = undefined; } // the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick pick( x: number, y: number, z: number, yaw: number, pitch: number, max_distance = 6, step = 0.05, ): BlockHitResult | undefined { const cos_pitch = Math.cos(pitch); const dx = -Math.sin(yaw) * cos_pitch; const dy = Math.sin(pitch); const dz = -Math.cos(yaw) * cos_pitch; let prev_bx = Math.floor(x); let prev_by = Math.floor(y); let prev_bz = Math.floor(z); let dist = 0; while (dist <= max_distance) { x += dx * step; y += dy * step; z += dz * step; dist += step; const bx = Math.floor(x); const by = Math.floor(y); const bz = Math.floor(z); if (bx === prev_bx && by === prev_by && bz === prev_bz) { continue; } const block = this.get_block(bx, by, bz); if (block && block !== AIR && block !== VOID) { let face: Faces; if (bx > prev_bx) { face = "west"; } else if (bx < prev_bx) { face = "east"; } else if (by > prev_by) { face = "bottom"; } else if (by < prev_by) { face = "top"; } else if (bz > prev_bz) { face = "north"; } else { face = "south"; } return { x: bx, y: by, z: bz, face, block }; } prev_bx = bx; prev_by = by; prev_bz = bz; } return undefined; } } // functions cant be sent to workers // deno-lint-ignore no-explicit-any function strip_functions>(obj: T): T { // deno-lint-ignore no-explicit-any const out: any = Array.isArray(obj) ? [] : {}; for (const k in obj) { const v = obj[k]; if (typeof v === "function") continue; if (v && typeof v === "object") { out[k] = strip_functions(v); } else { out[k] = v; } } return out; }