diff --git a/client/level/client_level.ts b/client/level/client_level.ts index 38b34b2..91b38d5 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -12,7 +12,7 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from 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 type { FaceGroup, FromChunkWorker } 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"; @@ -37,6 +37,9 @@ export interface Chunk { // bumped on every mesh request so late results from older requests get ignored mesh_version: number; meshes: Partial>; + // the lowest and highest y of its meshes, for frustum culling + min_y: number; + max_y: number; // only for translucent meshes that have to be sorted again as the camera moves translucent_sort?: TranslucentSort; // blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that @@ -49,6 +52,8 @@ export interface ChunkMesh { quad_count: number; // the translucent layer's quads sorted back to front, the others are drawn in order index_buffer?: GPUBuffer; + // solid and cutout quads by the way they face, see FACE_GROUPS in chunk_messages.ts + groups?: FaceGroup[]; } interface TranslucentSort { @@ -63,8 +68,6 @@ interface TranslucentSort { applied: number; } -const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS; - export { chunk_key }; // the block being looked at and which face of it @@ -199,6 +202,8 @@ export class ClientLevel { generated: false, mesh_version: 0, meshes: {}, + min_y: 0, + max_y: 0, }; this.chunks.set(chunk_key(x, z), chunk); return chunk; @@ -548,16 +553,15 @@ export class ClientLevel { } this.delete_chunk_mesh(chunk); + chunk.min_y = message.min_y; + chunk.max_y = message.max_y; for (const layer of RENDER_LAYERS) { - const { vertices, quad_count } = message[layer]; + const { vertices, quad_count, groups } = 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, - }; + chunk.meshes[layer] = { vertex_buffer: create_vertex_buffer(vertices), quad_count, groups }; } const translucent = message.translucent; diff --git a/client/renderer/core.ts b/client/renderer/core.ts index e080ce2..0adc1d3 100644 --- a/client/renderer/core.ts +++ b/client/renderer/core.ts @@ -1,7 +1,7 @@ import type { Camera } from "../camera.ts"; import { mat4 } from "gl-matrix"; import type { RenderLayer } from "$/common/everything_registry.ts"; -import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts"; +import { TERRAIN_VERTEX_BYTES } from "../workers/chunk_messages.ts"; export let device: GPUDevice; export let canvas: HTMLCanvasElement; @@ -375,10 +375,16 @@ export function flush_batch() { vert_index = 0; } -// draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order +// what's bound for terrain draws in the current pass, so drawing hundreds of chunks doesn't bind the same things +// again for each one. cleared whenever the pipeline changes +let terrain_binds: { slot: number; texture: GPUTexture; index_buffer: GPUBuffer } | undefined; + +// draws quads first to first + quad_count of a chunk mesh (4 vertices each). without an index buffer the quads are +// drawn in order export function draw_terrain( layer: RenderLayer, vertex_buffer: GPUBuffer, + first: number, quad_count: number, index_buffer?: GPUBuffer, ) { @@ -386,7 +392,7 @@ export function draw_terrain( return; } if (!index_buffer) { - ensure_quad_indices(quad_count); + ensure_quad_indices(first + quad_count); index_buffer = quad_index_buffer!; } @@ -395,17 +401,33 @@ export function draw_terrain( if (current_pipeline !== pipeline) { render_pass.setPipeline(pipeline); current_pipeline = pipeline; + terrain_binds = undefined; } - render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); - render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); - render_pass.setBindGroup(2, lightmap_bind_group); + if (!terrain_binds) { + render_pass.setBindGroup(2, lightmap_bind_group); + } + if (terrain_binds?.slot !== uniform_slot) { + render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); + } + if (terrain_binds?.texture !== current_texture) { + render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); + } + if (terrain_binds?.index_buffer !== index_buffer) { + render_pass.setIndexBuffer(index_buffer, "uint32"); + } + terrain_binds = { slot: uniform_slot, texture: current_texture, index_buffer }; + render_pass.setVertexBuffer(0, vertex_buffer); - render_pass.setIndexBuffer(index_buffer, "uint32"); - render_pass.drawIndexed(quad_count * 6); + render_pass.drawIndexed(quad_count * 6, 1, first * 6); } -export function create_vertex_buffer(vertices: Float32Array): GPUBuffer { +// the camera's view and projection, for frustum culling. only meaningful in 3d mode +export function view_projection(): Readonly { + return mvp as Float32Array; +} + +export function create_vertex_buffer(vertices: Float32Array | Uint8Array): GPUBuffer { const buffer = device.createBuffer({ size: Math.max(4, vertices.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, @@ -598,6 +620,7 @@ function ensure_pass(): GPURenderPassEncoder { pending_color_clear = false; pending_depth_clear = false; current_pipeline = undefined; + terrain_binds = undefined; apply_scissor(pass); return pass; @@ -762,12 +785,12 @@ function create_pipelines() { module: terrain_module, entryPoint: "vs_terrain", buffers: [{ - arrayStride: TERRAIN_VERTEX_FLOATS * 4, + arrayStride: TERRAIN_VERTEX_BYTES, attributes: [ { shaderLocation: 0, offset: 0, format: "float32x3" }, - { shaderLocation: 1, offset: 12, format: "float32x2" }, - { shaderLocation: 2, offset: 20, format: "float32x4" }, - { shaderLocation: 3, offset: 36, format: "float32x2" }, + { shaderLocation: 1, offset: 12, format: "unorm16x2" }, + { shaderLocation: 2, offset: 16, format: "unorm8x4" }, + { shaderLocation: 3, offset: 20, format: "unorm8x2" }, ], }], }; diff --git a/client/rendering/frustum.ts b/client/rendering/frustum.ts new file mode 100644 index 0000000..c817351 --- /dev/null +++ b/client/rendering/frustum.ts @@ -0,0 +1,41 @@ +// what the camera can see: the six planes around it, taken from its view projection matrix (gl-matrix's column major +// order, with webgpu's 0 to 1 depth). a box is out of view when it's entirely behind one of them +export class Frustum { + // a, b, c, d per plane, where a * x + b * y + c * z + d >= 0 is the inside + #planes = new Float32Array(24); + + update(m: Readonly) { + const planes = this.#planes; + const set = (i: number, a: number, b: number, c: number, d: number) => { + planes[i * 4] = a; + planes[i * 4 + 1] = b; + planes[i * 4 + 2] = c; + planes[i * 4 + 3] = d; + }; + // row r of the matrix is m[r], m[4 + r], m[8 + r], m[12 + r] + set(0, m[3] + m[0], m[7] + m[4], m[11] + m[8], m[15] + m[12]); // left + set(1, m[3] - m[0], m[7] - m[4], m[11] - m[8], m[15] - m[12]); // right + set(2, m[3] + m[1], m[7] + m[5], m[11] + m[9], m[15] + m[13]); // bottom + set(3, m[3] - m[1], m[7] - m[5], m[11] - m[9], m[15] - m[13]); // top + set(4, m[2], m[6], m[10], m[14]); // near, depth 0 + set(5, m[3] - m[2], m[7] - m[6], m[11] - m[10], m[15] - m[14]); // far + } + + // whether any of the box can be seen. may say yes for boxes just outside a corner, never no for one inside + intersects_box(min_x: number, min_y: number, min_z: number, max_x: number, max_y: number, max_z: number) { + const planes = this.#planes; + for (let i = 0; i < 24; i += 4) { + const a = planes[i]; + const b = planes[i + 1]; + const c = planes[i + 2]; + // the box's corner furthest along the plane's normal + const x = a > 0 ? max_x : min_x; + const y = b > 0 ? max_y : min_y; + const z = c > 0 ? max_z : min_z; + if (a * x + b * y + c * z + planes[i + 3] < 0) { + return false; + } + } + return true; + } +} diff --git a/client/rendering/level_renderer.ts b/client/rendering/level_renderer.ts index 9cb7ef5..72d80fb 100644 --- a/client/rendering/level_renderer.ts +++ b/client/rendering/level_renderer.ts @@ -1,5 +1,5 @@ import { TEXTURE_SIZE } from "$/common/constants.ts"; -import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts"; +import { type Chunk, CHUNK_SIZE, type ChunkMesh, ClientLevel } from "$/client/level/client_level.ts"; import { Camera } from "$/client/camera.ts"; import { AssetManager } from "$/client/assets.ts"; import { get_sprite_region } from "$/client/sprites.ts"; @@ -20,8 +20,13 @@ import { push_top_face, set_current_texture, Texture, + view_projection, white_tex, } from "$/client/renderer/mod.ts"; +import type { RenderLayer } from "$/common/everything_registry.ts"; +import { FACE_GROUPS, UNALIGNED_GROUP } from "$/client/workers/chunk_messages.ts"; +import { FACE_AXIS, FACE_NORMALS } from "$/client/workers/translucent_sort.ts"; +import { Frustum } from "./frustum.ts"; const BREAKING_FACES = [ push_back_face, @@ -34,48 +39,59 @@ const BREAKING_FACES = [ // draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities export class LevelRenderer { + frustum = new Frustum(); + // chunks in view this frame, worked out once for all the layers + #visible: Chunk[] = []; + // solid and cutout terrain, drawn before entities render_opaque(level: ClientLevel, camera: Camera) { level.request_meshes(camera); level.update_translucent_sorting(camera); - set_current_texture(level.image.tex); - + this.frustum.update(view_projection()); + this.#visible.length = 0; for (const chunk of level.chunks.values()) { - const mesh = chunk.meshes.solid; - if (mesh) { - draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count); + if (Object.keys(chunk.meshes).length > 0 && this.#in_view(chunk)) { + this.#visible.push(chunk); } } - for (const chunk of level.chunks.values()) { - const mesh = chunk.meshes.cutout; - if (mesh) { - draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count); + set_current_texture(level.image.tex); + + for (const layer of ["solid", "cutout"] as const) { + for (const chunk of this.#visible) { + const mesh = chunk.meshes[layer]; + if (mesh) { + draw_facing_camera(layer, mesh, camera); + } } } } // translucent terrain, drawn after entities so they show through water and glass. // chunks go back to front, and each chunk's quads are already sorted back to front - render_translucent(level: ClientLevel, camera: Camera) { + render_translucent(_level: ClientLevel, camera: Camera) { const distance_sq = (x: number, z: number) => { const dx = (x + 0.5) * CHUNK_SIZE - camera.x; const dz = (z + 0.5) * CHUNK_SIZE - camera.z; return dx * dx + dz * dz; }; - const chunks = [...level.chunks.values()] + const chunks = this.#visible .filter((chunk) => chunk.meshes.translucent) .map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) })) .sort((a, b) => b.distance - a.distance); - set_current_texture(level.image.tex); - for (const { mesh } of chunks) { - draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer); + draw_terrain("translucent", mesh.vertex_buffer, 0, mesh.quad_count, mesh.index_buffer); } } + #in_view(chunk: Chunk) { + const x = chunk.x * CHUNK_SIZE; + const z = chunk.z * CHUNK_SIZE; + return this.frustum.intersects_box(x, chunk.min_y, z, x + CHUNK_SIZE, chunk.max_y, z + CHUNK_SIZE); + } + // every entity but the one the camera is in render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) { flush_batch(); @@ -127,6 +143,44 @@ export class LevelRenderer { } } +// only the face groups that can face the camera: quads facing +x can only be seen from beyond the lowest x plane any +// of them lie on. neighboring groups that are both drawn go in one draw call +function draw_facing_camera(layer: RenderLayer, mesh: ChunkMesh, camera: Camera) { + const groups = mesh.groups; + if (!groups) { + draw_terrain(layer, mesh.vertex_buffer, 0, mesh.quad_count); + return; + } + let start = 0; + let end = 0; + for (let g = 0; g < FACE_GROUPS; g++) { + const group = groups[g]; + if (group.count === 0 || !group_faces_camera(g, group.min, group.max, camera)) { + continue; + } + if (group.first !== end) { + draw_terrain(layer, mesh.vertex_buffer, start, end - start); + start = group.first; + } + end = group.first + group.count; + } + draw_terrain(layer, mesh.vertex_buffer, start, end - start); +} + +export function group_faces_camera( + group: number, + min: number, + max: number, + camera: { x: number; y: number; z: number }, +) { + if (group === UNALIGNED_GROUP) { + return true; + } + const axis = FACE_AXIS[group]; + const position = axis === 0 ? camera.x : axis === 1 ? camera.y : camera.z; + return FACE_NORMALS[group][axis] > 0 ? position > min : position < max; +} + // a box body and head in the player's color function render_player(player: RemotePlayer, partial_tick: number) { const [r, g, b] = player.color; diff --git a/client/workers/chunk_messages.ts b/client/workers/chunk_messages.ts index 032ee67..afd50ce 100644 --- a/client/workers/chunk_messages.ts +++ b/client/workers/chunk_messages.ts @@ -6,8 +6,23 @@ import type { SortType } from "./translucent_sort.ts"; // messages between the main thread and the chunk workers -// position 3, uv 2, color 4 (directional shade and ambient occlusion, alpha), lightmap coordinates 2 -export const TERRAIN_VERTEX_FLOATS = 11; +// a terrain vertex, 24 bytes: position as float32x3, atlas uv as unorm16x2, directional shade times ambient occlusion +// and alpha as unorm8x4 (shade repeated in rgb), and lightmap coordinates as unorm8x2 plus two unused bytes +export const TERRAIN_VERTEX_BYTES = 24; +export const TERRAIN_QUAD_BYTES = 4 * TERRAIN_VERTEX_BYTES; + +// solid and cutout quads are grouped by the way they face, so groups facing away from the camera can be skipped, like +// sodium's block face culling. the groups follow FACE_NORMALS' order, then the quads that aren't axis aligned +export const FACE_GROUPS = 7; +export const UNALIGNED_GROUP = 6; + +export interface FaceGroup { + first: number; + count: number; + // the lowest and highest plane the group's quads lie on, along its axis + min: number; + max: number; +} export type ToChunkWorker = | { @@ -44,10 +59,12 @@ export type ToChunkWorker = camera: number[]; }; -// 4 vertices per quad +// 4 vertices per quad, TERRAIN_VERTEX_BYTES each export interface LayerMesh { - vertices: Float32Array; + vertices: Uint8Array; quad_count: number; + // solid and cutout only, see FACE_GROUPS. the quads are stored group after group + groups?: FaceGroup[]; } export type FromChunkWorker = @@ -64,6 +81,9 @@ export type FromChunkWorker = chunk_x: number; chunk_z: number; version: number; + // the lowest and highest y of the chunk's quads, for frustum culling + min_y: number; + max_y: number; solid: LayerMesh; cutout: LayerMesh; translucent: LayerMesh & { diff --git a/client/workers/chunk_worker.ts b/client/workers/chunk_worker.ts index 519fc38..98a4a83 100644 --- a/client/workers/chunk_worker.ts +++ b/client/workers/chunk_worker.ts @@ -16,13 +16,22 @@ import { TEXTURE_SIZE, } from "$/common/constants.ts"; import type { Texture } from "../renderer/types.ts"; -import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS, type ToChunkWorker } from "./chunk_messages.ts"; +import { + FACE_GROUPS, + type FaceGroup, + type FromChunkWorker, + TERRAIN_QUAD_BYTES, + TERRAIN_VERTEX_BYTES, + type ToChunkWorker, + UNALIGNED_GROUP, +} from "./chunk_messages.ts"; import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts"; import { default_block_value, get_state_value } from "$/common/utils.ts"; import { bake_model, block_variant, FACE_CORNERS, find_model, type ModelJson } from "$/common/block_models.ts"; import { choose_sort_type, + FACE_AXIS, FACE_NORMALS, quad_indices, quad_planes, @@ -42,7 +51,6 @@ import { type TexturesInfo = Record; -const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS; // keeps texture lookups off the sprite's edge const UV_PAD = 0.5; @@ -91,6 +99,8 @@ interface MeshQuad { face: number; cull: number; flush: boolean; + // the face group it goes in, see FACE_GROUPS + group: number; shade: number; sprite: SpriteRegion; // 4 weights per corner @@ -132,7 +142,7 @@ self.onmessage = async (event: MessageEvent) => { case "mesh": { region.fill(message.chunks); region.compute(light_tables); - const { solid, cutout, translucent } = make_chunk_mesh( + const { solid, cutout, translucent, min_y, max_y } = make_chunk_mesh( message.chunk_x, message.chunk_z, message.chunks[4]!, @@ -144,6 +154,8 @@ self.onmessage = async (event: MessageEvent) => { chunk_x: message.chunk_x, chunk_z: message.chunk_z, version: message.version, + min_y, + max_y, solid, cutout, translucent, @@ -285,28 +297,57 @@ function should_flip() { return light(0) + light(2) > light(1) + light(3); } -function push_quad(vertices: Float32Array, i: number, quad: MeshQuad, x: number, y: number, z: number, alpha: number) { +// quads being built, in the terrain vertex format +class QuadBuffer { + count = 0; + bytes = new Uint8Array(TERRAIN_QUAD_BYTES * 64); + f32 = new Float32Array(this.bytes.buffer); + u16 = new Uint16Array(this.bytes.buffer); + + // room for one more quad + reserve() { + if ((this.count + 1) * TERRAIN_QUAD_BYTES <= this.bytes.length) return; + const bytes = new Uint8Array(this.bytes.length * 2); + bytes.set(this.bytes); + this.bytes = bytes; + this.f32 = new Float32Array(bytes.buffer); + this.u16 = new Uint16Array(bytes.buffer); + } +} + +// the lowest and highest y of any quad in the chunk being meshed +let mesh_min_y = Infinity; +let mesh_max_y = -Infinity; + +function push_quad(buffer: QuadBuffer, quad: MeshQuad, x: number, y: number, z: number, alpha: number) { + buffer.reserve(); + const { f32, u16, bytes } = buffer; const sprite = quad.sprite; // starting from the second corner moves the diagonal, the winding stays the same const first = should_flip() ? 1 : 0; + const alpha_byte = Math.round(alpha * 255); for (let k = 0; k < 4; k++) { const corner = (first + k) & 3; - const brightness = quad.shade * corner_ao[corner]; - vertices[i++] = x + quad.positions[corner * 3]; - vertices[i++] = y + quad.positions[corner * 3 + 1]; - vertices[i++] = z + quad.positions[corner * 3 + 2]; - vertices[i++] = atlas_u(sprite, quad.uvs[corner * 2]); - vertices[i++] = atlas_v(sprite, quad.uvs[corner * 2 + 1]); - vertices[i++] = brightness; - vertices[i++] = brightness; - vertices[i++] = brightness; - vertices[i++] = alpha; + const byte = (buffer.count * 4 + k) * TERRAIN_VERTEX_BYTES; + const vy = y + quad.positions[corner * 3 + 1]; + f32[byte / 4] = x + quad.positions[corner * 3]; + f32[byte / 4 + 1] = vy; + f32[byte / 4 + 2] = z + quad.positions[corner * 3 + 2]; + u16[byte / 2 + 6] = Math.round(atlas_u(sprite, quad.uvs[corner * 2]) * 65535); + u16[byte / 2 + 7] = Math.round(atlas_v(sprite, quad.uvs[corner * 2 + 1]) * 65535); + const brightness = Math.round(quad.shade * corner_ao[corner] * 255); + bytes[byte + 16] = brightness; + bytes[byte + 17] = brightness; + bytes[byte + 18] = brightness; + bytes[byte + 19] = alpha_byte; // where to read the lightmap, block light across and sky light down - vertices[i++] = (corner_block[corner] + 0.5) / 16; - vertices[i++] = (corner_sky[corner] + 0.5) / 16; + bytes[byte + 20] = Math.round((corner_block[corner] + 0.5) / 16 * 255); + bytes[byte + 21] = Math.round((corner_sky[corner] + 0.5) / 16 * 255); + if (vy < mesh_min_y) mesh_min_y = vy; + if (vy > mesh_max_y) mesh_max_y = vy; } - return i; + buffer.count += 1; } // pixels of a sprite to atlas coordinates, kept off the sprite's edge @@ -340,6 +381,7 @@ function block_quads(value: number): MeshQuad[] { face: quad.face, cull: quad.cull, flush: quad.flush, + group: quad.aligned ? quad.face : UNALIGNED_GROUP, shade: quad.shade ? FACE_SHADE[quad.face] : 1, sprite: textures_info[quad.texture] ?? textures_info["engine:missing"], light_weights: light_weights(quad.face, quad.positions), @@ -408,7 +450,13 @@ function light_quad(quad: MeshQuad, index: number, y: number, face_offsets: numb // region has to be filled and lit first // values is the middle chunk's blocks with their states, for models that change with them function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, camera: number[]) { - const layers = [SOLID, CUTOUT, TRANSLUCENT].map(() => ({ vertices: new Float32Array(4096), floats: 0 })); + // solid and cutout get a buffer per face group, translucent one for everything since it's sorted instead + const opaque = [SOLID, CUTOUT].map(() => + Array.from({ length: FACE_GROUPS }, () => ({ buffer: new QuadBuffer(), min: Infinity, max: -Infinity })) + ); + const translucent_quads = new QuadBuffer(); + mesh_min_y = Infinity; + mesh_max_y = -Infinity; // for sorting the translucent quads let centers = new Float32Array(256); let faces = new Uint8Array(256); @@ -426,7 +474,6 @@ function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, const block_info = blocks_registry[block_nid]; const layer_id = block_layers[block_nid]; - const layer = layers[layer_id]; const alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1; const wx = chunk_x * CHUNK_SIZE + x; @@ -443,12 +490,20 @@ function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, } light_quad(quad, index, y, face_offsets); - layer.vertices = ensure_capacity(layer.vertices, layer.floats + FLOATS_PER_QUAD); - layer.floats = push_quad(layer.vertices, layer.floats, quad, wx, y, wz, alpha); - if (layer_id === TRANSLUCENT) { + if (layer_id !== TRANSLUCENT) { + const group = opaque[layer_id][quad.group]; + push_quad(group.buffer, quad, wx, y, wz, alpha); + if (quad.group !== UNALIGNED_GROUP) { + const axis = FACE_AXIS[quad.face]; + const plane = (axis === 0 ? wx : axis === 1 ? y : wz) + quad.positions[axis]; + if (plane < group.min) group.min = plane; + if (plane > group.max) group.max = plane; + } + } else { + push_quad(translucent_quads, quad, wx, y, wz, alpha); // sorting treats every quad as facing along an axis, rotated ones too - const q = layer.floats / FLOATS_PER_QUAD - 1; + const q = translucent_quads.count - 1; centers = ensure_capacity(centers, (q + 1) * 3); faces = ensure_capacity(faces, q + 1); const p = quad.positions; @@ -462,16 +517,31 @@ function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, } } - const [solid, cutout, translucent] = layers.map((layer) => ({ - vertices: layer.vertices, - quad_count: layer.floats / FLOATS_PER_QUAD, - })); + // each layer's groups one after another, in one buffer + const [solid, cutout] = opaque.map((groups) => { + const quad_count = groups.reduce((sum, group) => sum + group.buffer.count, 0); + const vertices = new Uint8Array(quad_count * TERRAIN_QUAD_BYTES); + const ranges: FaceGroup[] = []; + let first = 0; + for (const { buffer, min, max } of groups) { + vertices.set(buffer.bytes.subarray(0, buffer.count * TERRAIN_QUAD_BYTES), first * TERRAIN_QUAD_BYTES); + ranges.push({ first, count: buffer.count, min, max }); + first += buffer.count; + } + return { vertices, quad_count, groups: ranges }; + }); + const translucent = { + vertices: translucent_quads.bytes.slice(0, translucent_quads.count * TERRAIN_QUAD_BYTES), + quad_count: translucent_quads.count, + }; const quads = { centers, faces, count: translucent.quad_count }; const sort_type = choose_sort_type(quads); const [camera_x, camera_y, camera_z] = camera; return { + min_y: mesh_min_y === Infinity ? 0 : mesh_min_y, + max_y: mesh_max_y === -Infinity ? 0 : mesh_max_y, solid, cutout, translucent: { diff --git a/common/block_models.ts b/common/block_models.ts index d92d7a2..78f85f7 100644 --- a/common/block_models.ts +++ b/common/block_models.ts @@ -180,6 +180,9 @@ export interface BakedQuad { cull: number; // on the block's edge facing straight out, so it's lit like a full block's face flush: boolean; + // flat on a plane facing straight along face's axis, so it can only be seen from that side of the plane. + // rotated quads, like the cross model's, aren't + aligned: boolean; shade: boolean; } @@ -245,7 +248,7 @@ export function bake_model(model: ModelJson, textures: BlockTextures | undefined let cull = face_json.cullface ? FACE_INDEX[face_json.cullface] : -1; for (let t = 0; t < turns && cull >= 0; t++) cull = TURN_Y[cull]; - const { face: facing, flush } = classify(positions); + const { face: facing, flush, aligned } = classify(positions); quads.push({ positions, uvs, @@ -253,6 +256,7 @@ export function bake_model(model: ModelJson, textures: BlockTextures | undefined face: facing, cull, flush, + aligned, shade: element.shade ?? true, }); } @@ -285,8 +289,8 @@ function element_rotation(element: ModelElementJson): (point: number[]) => numbe }; } -// which way a quad faces most, and whether it's flat against the block's edge -function classify(p: number[]): { face: number; flush: boolean } { +// which way a quad faces most, whether it's flat against the block's edge, and whether it faces straight along an axis +function classify(p: number[]): { face: number; flush: boolean; aligned: boolean } { const e1 = [p[3] - p[0], p[4] - p[1], p[5] - p[2]]; const e2 = [p[9] - p[0], p[10] - p[1], p[11] - p[2]]; const normal = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]]; @@ -303,6 +307,7 @@ function classify(p: number[]): { face: number; flush: boolean } { const axis = FACE_NORMALS[face].findIndex((v) => v !== 0); const edge = FACE_NORMALS[face][axis] > 0 ? 1 : 0; - const flush = [0, 1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - edge) < EPSILON); - return { face, flush }; + const aligned = [1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - p[axis]) < EPSILON); + const flush = aligned && Math.abs(p[axis] - edge) < EPSILON; + return { face, flush, aligned }; } diff --git a/tests/terrain_culling_test.ts b/tests/terrain_culling_test.ts new file mode 100644 index 0000000..24f37ac --- /dev/null +++ b/tests/terrain_culling_test.ts @@ -0,0 +1,135 @@ +// 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`); +});