// the renderer skips chunks outside the view and faces pointing away from the camera, but must never skip one it // would have shown import { assert, assertEquals } from "@std/assert"; import { mat4 } from "gl-matrix"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { Frustum } from "$/client/rendering/frustum.ts"; import { group_faces_camera } from "$/client/rendering/level_renderer.ts"; import { FACE_GROUPS, type LayerMesh, TERRAIN_VERTEX_BYTES } from "$/client/workers/chunk_messages.ts"; import { test_game } from "./helpers.ts"; // the matrix the renderer makes for a camera, see update_camera in client/renderer/core.ts function camera_matrix(x: number, y: number, z: number, yaw: number, pitch: number) { const proj = mat4.perspectiveZO(mat4.create(), Math.PI / 3, 16 / 9, 0.1, 1000); const view = mat4.create(); mat4.rotateX(view, view, -pitch); mat4.rotateY(view, view, -yaw); mat4.translate(view, view, [-x, -y, -z]); return mat4.multiply(mat4.create(), proj, view) as Float32Array; } Deno.test("the frustum keeps what's in front of the camera and drops what's behind or beside it", () => { const frustum = new Frustum(); // yaw 0 looks toward -z frustum.update(camera_matrix(0, 70, 0, 0, 0)); assert(frustum.intersects_box(-8, 60, -40, 8, 80, -24), "straight ahead"); assert(frustum.intersects_box(-1, 0, -1, 1, 256, 1), "the chunk the camera is in"); assert(!frustum.intersects_box(-8, 60, 24, 8, 80, 40), "behind"); assert(!frustum.intersects_box(200, 60, -40, 216, 80, -24), "far off to the side"); assert(!frustum.intersects_box(-8, 60, -1100, 8, 80, -1090), "past the far plane"); }); // real chunks from the chunk worker, meshed the way the client does it async function mesh_chunks(radius: number) { const { game } = await test_game("mods", undefined, "culling-seed"); const blocks = EverythingRegistry.get_registry("blocks"); const block_ids: Record = {}; blocks.forEach((b, nid) => block_ids[b.id] = nid); const worker = new Worker(new URL("../client/workers/chunk_worker.ts", import.meta.url).href, { type: "module" }); // deno-lint-ignore no-explicit-any const replies = new Map void>(); worker.onmessage = (e) => { const key = `${e.data.type} ${e.data.chunk_x} ${e.data.chunk_z}`; replies.get(key)?.(e.data); replies.delete(key); }; // deno-lint-ignore no-explicit-any const ask = (message: any, reply: string) => // deno-lint-ignore no-explicit-any new Promise((resolve) => { replies.set(`${reply} ${message.chunk_x} ${message.chunk_z}`, resolve); worker.postMessage(message); }); worker.postMessage({ type: "init", blocks_registry: JSON.parse(JSON.stringify(blocks)), block_ids, models: {}, textures_info: { "engine:missing": { x: 0, y: 0 } }, image: { width: 1024, height: 1024 }, worldgen_scripts: [], ores: game.recipes.ores, }); const generated = new Map(); for (let x = -radius - 1; x <= radius + 1; x++) { for (let z = -radius - 1; z <= radius + 1; z++) { const reply = await ask({ type: "generate", chunk_x: x, chunk_z: z, seed: "culling-seed" }, "generated"); generated.set(`${x},${z}`, reply.blocks); } } const meshes = []; for (let x = -radius; x <= radius; x++) { for (let z = -radius; z <= radius; z++) { const chunks = []; for (let dz = -1; dz <= 1; dz++) { for (let dx = -1; dx <= 1; dx++) chunks.push(generated.get(`${x + dx},${z + dz}`)!.slice()); } meshes.push( await ask({ type: "mesh", chunk_x: x, chunk_z: z, version: 1, chunks, camera: [0, 0, 0] }, "meshed"), ); } } worker.terminate(); return meshes; } // each quad's corners, read back out of the vertex format function quad_corners(mesh: LayerMesh, quad: number) { const f32 = new Float32Array(mesh.vertices.buffer, mesh.vertices.byteOffset, mesh.vertices.byteLength / 4); return [0, 1, 2, 3].map((k) => { const i = ((quad * 4 + k) * TERRAIN_VERTEX_BYTES) / 4; return [f32[i], f32[i + 1], f32[i + 2]]; }); } Deno.test("face groups only skip quads that face away from the camera", async () => { const meshes = await mesh_chunks(1); const cameras = [[8, 70, 8], [-20, 120, 30], [24, 20, -5], [8, 300, 8], [0.5, 64.5, 15.5]]; let checked = 0; for (const message of meshes) { for (const layer of ["solid", "cutout"] as const) { const mesh: LayerMesh = message[layer]; if (mesh.quad_count === 0) continue; assertEquals(mesh.groups!.length, FACE_GROUPS); assertEquals(mesh.groups!.reduce((sum, g) => sum + g.count, 0), mesh.quad_count); for (const [cx, cy, cz] of cameras) { for (const [g, group] of mesh.groups!.entries()) { if (group_faces_camera(g, group.min, group.max, { x: cx, y: cy, z: cz })) continue; // a skipped group: every quad in it faces away from the camera, or is edge on for (let q = group.first; q < group.first + group.count; q++) { const [a, b, , d] = quad_corners(mesh, q); const e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const e2 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]]; const n = [ e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0], ]; const facing = n[0] * (cx - a[0]) + n[1] * (cy - a[1]) + n[2] * (cz - a[2]); assert(facing <= 1e-4, `group ${g} skipped a quad facing the camera at ${cx},${cy},${cz}`); checked++; } } } } // the bounds hold every quad for (const layer of ["solid", "cutout", "translucent"] as const) { const mesh: LayerMesh = message[layer]; for (let q = 0; q < mesh.quad_count; q++) { for (const [, y] of quad_corners(mesh, q)) assert(y >= message.min_y && y <= message.max_y); } } } assert(checked > 1000, `only checked ${checked} quads`); });