Optimize renderer

This commit is contained in:
2026-09-26 11:38:32 -03:00
parent 8b549162c4
commit 58afaac821
8 changed files with 424 additions and 72 deletions
+12 -8
View File
@@ -12,7 +12,7 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.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 { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
import { crosses_planes } from "../workers/translucent_sort.ts"; import { crosses_planes } from "../workers/translucent_sort.ts";
import { Camera } from "../camera.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 // bumped on every mesh request so late results from older requests get ignored
mesh_version: number; mesh_version: number;
meshes: Partial<Record<RenderLayer, ChunkMesh>>; meshes: Partial<Record<RenderLayer, ChunkMesh>>;
// 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 // only for translucent meshes that have to be sorted again as the camera moves
translucent_sort?: TranslucentSort; translucent_sort?: TranslucentSort;
// blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that // 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; quad_count: number;
// the translucent layer's quads sorted back to front, the others are drawn in order // the translucent layer's quads sorted back to front, the others are drawn in order
index_buffer?: GPUBuffer; index_buffer?: GPUBuffer;
// solid and cutout quads by the way they face, see FACE_GROUPS in chunk_messages.ts
groups?: FaceGroup[];
} }
interface TranslucentSort { interface TranslucentSort {
@@ -63,8 +68,6 @@ interface TranslucentSort {
applied: number; applied: number;
} }
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
export { chunk_key }; export { chunk_key };
// the block being looked at and which face of it // the block being looked at and which face of it
@@ -199,6 +202,8 @@ export class ClientLevel {
generated: false, generated: false,
mesh_version: 0, mesh_version: 0,
meshes: {}, meshes: {},
min_y: 0,
max_y: 0,
}; };
this.chunks.set(chunk_key(x, z), chunk); this.chunks.set(chunk_key(x, z), chunk);
return chunk; return chunk;
@@ -548,16 +553,15 @@ export class ClientLevel {
} }
this.delete_chunk_mesh(chunk); this.delete_chunk_mesh(chunk);
chunk.min_y = message.min_y;
chunk.max_y = message.max_y;
for (const layer of RENDER_LAYERS) { for (const layer of RENDER_LAYERS) {
const { vertices, quad_count } = message[layer]; const { vertices, quad_count, groups } = message[layer];
if (quad_count === 0) { if (quad_count === 0) {
continue; continue;
} }
chunk.meshes[layer] = { chunk.meshes[layer] = { vertex_buffer: create_vertex_buffer(vertices), quad_count, groups };
vertex_buffer: create_vertex_buffer(vertices.subarray(0, quad_count * FLOATS_PER_QUAD)),
quad_count,
};
} }
const translucent = message.translucent; const translucent = message.translucent;
+36 -13
View File
@@ -1,7 +1,7 @@
import type { Camera } from "../camera.ts"; import type { Camera } from "../camera.ts";
import { mat4 } from "gl-matrix"; import { mat4 } from "gl-matrix";
import type { RenderLayer } from "$/common/everything_registry.ts"; 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 device: GPUDevice;
export let canvas: HTMLCanvasElement; export let canvas: HTMLCanvasElement;
@@ -375,10 +375,16 @@ export function flush_batch() {
vert_index = 0; 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( export function draw_terrain(
layer: RenderLayer, layer: RenderLayer,
vertex_buffer: GPUBuffer, vertex_buffer: GPUBuffer,
first: number,
quad_count: number, quad_count: number,
index_buffer?: GPUBuffer, index_buffer?: GPUBuffer,
) { ) {
@@ -386,7 +392,7 @@ export function draw_terrain(
return; return;
} }
if (!index_buffer) { if (!index_buffer) {
ensure_quad_indices(quad_count); ensure_quad_indices(first + quad_count);
index_buffer = quad_index_buffer!; index_buffer = quad_index_buffer!;
} }
@@ -395,17 +401,33 @@ export function draw_terrain(
if (current_pipeline !== pipeline) { if (current_pipeline !== pipeline) {
render_pass.setPipeline(pipeline); render_pass.setPipeline(pipeline);
current_pipeline = pipeline; current_pipeline = pipeline;
terrain_binds = undefined;
} }
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); if (!terrain_binds) {
render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); render_pass.setBindGroup(2, lightmap_bind_group);
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.setVertexBuffer(0, vertex_buffer);
render_pass.setIndexBuffer(index_buffer, "uint32"); render_pass.drawIndexed(quad_count * 6, 1, first * 6);
render_pass.drawIndexed(quad_count * 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<Float32Array> {
return mvp as Float32Array;
}
export function create_vertex_buffer(vertices: Float32Array<ArrayBuffer> | Uint8Array<ArrayBuffer>): GPUBuffer {
const buffer = device.createBuffer({ const buffer = device.createBuffer({
size: Math.max(4, vertices.byteLength), size: Math.max(4, vertices.byteLength),
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
@@ -598,6 +620,7 @@ function ensure_pass(): GPURenderPassEncoder {
pending_color_clear = false; pending_color_clear = false;
pending_depth_clear = false; pending_depth_clear = false;
current_pipeline = undefined; current_pipeline = undefined;
terrain_binds = undefined;
apply_scissor(pass); apply_scissor(pass);
return pass; return pass;
@@ -762,12 +785,12 @@ function create_pipelines() {
module: terrain_module, module: terrain_module,
entryPoint: "vs_terrain", entryPoint: "vs_terrain",
buffers: [{ buffers: [{
arrayStride: TERRAIN_VERTEX_FLOATS * 4, arrayStride: TERRAIN_VERTEX_BYTES,
attributes: [ attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "float32x2" }, { shaderLocation: 1, offset: 12, format: "unorm16x2" },
{ shaderLocation: 2, offset: 20, format: "float32x4" }, { shaderLocation: 2, offset: 16, format: "unorm8x4" },
{ shaderLocation: 3, offset: 36, format: "float32x2" }, { shaderLocation: 3, offset: 20, format: "unorm8x2" },
], ],
}], }],
}; };
+41
View File
@@ -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<Float32Array>) {
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;
}
}
+69 -15
View File
@@ -1,5 +1,5 @@
import { TEXTURE_SIZE } from "$/common/constants.ts"; 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 { Camera } from "$/client/camera.ts";
import { AssetManager } from "$/client/assets.ts"; import { AssetManager } from "$/client/assets.ts";
import { get_sprite_region } from "$/client/sprites.ts"; import { get_sprite_region } from "$/client/sprites.ts";
@@ -20,8 +20,13 @@ import {
push_top_face, push_top_face,
set_current_texture, set_current_texture,
Texture, Texture,
view_projection,
white_tex, white_tex,
} from "$/client/renderer/mod.ts"; } 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 = [ const BREAKING_FACES = [
push_back_face, push_back_face,
@@ -34,48 +39,59 @@ const BREAKING_FACES = [
// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities // draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities
export class LevelRenderer { 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 // solid and cutout terrain, drawn before entities
render_opaque(level: ClientLevel, camera: Camera) { render_opaque(level: ClientLevel, camera: Camera) {
level.request_meshes(camera); level.request_meshes(camera);
level.update_translucent_sorting(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()) { for (const chunk of level.chunks.values()) {
const mesh = chunk.meshes.solid; if (Object.keys(chunk.meshes).length > 0 && this.#in_view(chunk)) {
if (mesh) { this.#visible.push(chunk);
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
} }
} }
for (const chunk of level.chunks.values()) { set_current_texture(level.image.tex);
const mesh = chunk.meshes.cutout;
if (mesh) { for (const layer of ["solid", "cutout"] as const) {
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count); 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. // 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 // 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 distance_sq = (x: number, z: number) => {
const dx = (x + 0.5) * CHUNK_SIZE - camera.x; const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
const dz = (z + 0.5) * CHUNK_SIZE - camera.z; const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
return dx * dx + dz * dz; return dx * dx + dz * dz;
}; };
const chunks = [...level.chunks.values()] const chunks = this.#visible
.filter((chunk) => chunk.meshes.translucent) .filter((chunk) => chunk.meshes.translucent)
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) })) .map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
.sort((a, b) => b.distance - a.distance); .sort((a, b) => b.distance - a.distance);
set_current_texture(level.image.tex);
for (const { mesh } of chunks) { 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 // every entity but the one the camera is in
render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) { render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) {
flush_batch(); 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 // a box body and head in the player's color
function render_player(player: RemotePlayer, partial_tick: number) { function render_player(player: RemotePlayer, partial_tick: number) {
const [r, g, b] = player.color; const [r, g, b] = player.color;
+24 -4
View File
@@ -6,8 +6,23 @@ import type { SortType } from "./translucent_sort.ts";
// messages between the main thread and the chunk workers // messages between the main thread and the chunk workers
// position 3, uv 2, color 4 (directional shade and ambient occlusion, alpha), lightmap coordinates 2 // a terrain vertex, 24 bytes: position as float32x3, atlas uv as unorm16x2, directional shade times ambient occlusion
export const TERRAIN_VERTEX_FLOATS = 11; // 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 = export type ToChunkWorker =
| { | {
@@ -44,10 +59,12 @@ export type ToChunkWorker =
camera: number[]; camera: number[];
}; };
// 4 vertices per quad // 4 vertices per quad, TERRAIN_VERTEX_BYTES each
export interface LayerMesh { export interface LayerMesh {
vertices: Float32Array; vertices: Uint8Array<ArrayBuffer>;
quad_count: number; quad_count: number;
// solid and cutout only, see FACE_GROUPS. the quads are stored group after group
groups?: FaceGroup[];
} }
export type FromChunkWorker = export type FromChunkWorker =
@@ -64,6 +81,9 @@ export type FromChunkWorker =
chunk_x: number; chunk_x: number;
chunk_z: number; chunk_z: number;
version: number; version: number;
// the lowest and highest y of the chunk's quads, for frustum culling
min_y: number;
max_y: number;
solid: LayerMesh; solid: LayerMesh;
cutout: LayerMesh; cutout: LayerMesh;
translucent: LayerMesh & { translucent: LayerMesh & {
+97 -27
View File
@@ -16,13 +16,22 @@ import {
TEXTURE_SIZE, TEXTURE_SIZE,
} from "$/common/constants.ts"; } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.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 { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { default_block_value, get_state_value } from "$/common/utils.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 { bake_model, block_variant, FACE_CORNERS, find_model, type ModelJson } from "$/common/block_models.ts";
import { import {
choose_sort_type, choose_sort_type,
FACE_AXIS,
FACE_NORMALS, FACE_NORMALS,
quad_indices, quad_indices,
quad_planes, quad_planes,
@@ -42,7 +51,6 @@ import {
type TexturesInfo = Record<string, SpriteRegion>; type TexturesInfo = Record<string, SpriteRegion>;
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
// keeps texture lookups off the sprite's edge // keeps texture lookups off the sprite's edge
const UV_PAD = 0.5; const UV_PAD = 0.5;
@@ -91,6 +99,8 @@ interface MeshQuad {
face: number; face: number;
cull: number; cull: number;
flush: boolean; flush: boolean;
// the face group it goes in, see FACE_GROUPS
group: number;
shade: number; shade: number;
sprite: SpriteRegion; sprite: SpriteRegion;
// 4 weights per corner // 4 weights per corner
@@ -132,7 +142,7 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
case "mesh": { case "mesh": {
region.fill(message.chunks); region.fill(message.chunks);
region.compute(light_tables); 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_x,
message.chunk_z, message.chunk_z,
message.chunks[4]!, message.chunks[4]!,
@@ -144,6 +154,8 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
chunk_x: message.chunk_x, chunk_x: message.chunk_x,
chunk_z: message.chunk_z, chunk_z: message.chunk_z,
version: message.version, version: message.version,
min_y,
max_y,
solid, solid,
cutout, cutout,
translucent, translucent,
@@ -285,28 +297,57 @@ function should_flip() {
return light(0) + light(2) > light(1) + light(3); 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; const sprite = quad.sprite;
// starting from the second corner moves the diagonal, the winding stays the same // starting from the second corner moves the diagonal, the winding stays the same
const first = should_flip() ? 1 : 0; const first = should_flip() ? 1 : 0;
const alpha_byte = Math.round(alpha * 255);
for (let k = 0; k < 4; k++) { for (let k = 0; k < 4; k++) {
const corner = (first + k) & 3; const corner = (first + k) & 3;
const brightness = quad.shade * corner_ao[corner]; const byte = (buffer.count * 4 + k) * TERRAIN_VERTEX_BYTES;
vertices[i++] = x + quad.positions[corner * 3]; const vy = y + quad.positions[corner * 3 + 1];
vertices[i++] = y + quad.positions[corner * 3 + 1]; f32[byte / 4] = x + quad.positions[corner * 3];
vertices[i++] = z + quad.positions[corner * 3 + 2]; f32[byte / 4 + 1] = vy;
vertices[i++] = atlas_u(sprite, quad.uvs[corner * 2]); f32[byte / 4 + 2] = z + quad.positions[corner * 3 + 2];
vertices[i++] = atlas_v(sprite, quad.uvs[corner * 2 + 1]); u16[byte / 2 + 6] = Math.round(atlas_u(sprite, quad.uvs[corner * 2]) * 65535);
vertices[i++] = brightness; u16[byte / 2 + 7] = Math.round(atlas_v(sprite, quad.uvs[corner * 2 + 1]) * 65535);
vertices[i++] = brightness; const brightness = Math.round(quad.shade * corner_ao[corner] * 255);
vertices[i++] = brightness; bytes[byte + 16] = brightness;
vertices[i++] = alpha; 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 // where to read the lightmap, block light across and sky light down
vertices[i++] = (corner_block[corner] + 0.5) / 16; bytes[byte + 20] = Math.round((corner_block[corner] + 0.5) / 16 * 255);
vertices[i++] = (corner_sky[corner] + 0.5) / 16; 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 // 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, face: quad.face,
cull: quad.cull, cull: quad.cull,
flush: quad.flush, flush: quad.flush,
group: quad.aligned ? quad.face : UNALIGNED_GROUP,
shade: quad.shade ? FACE_SHADE[quad.face] : 1, shade: quad.shade ? FACE_SHADE[quad.face] : 1,
sprite: textures_info[quad.texture] ?? textures_info["engine:missing"], sprite: textures_info[quad.texture] ?? textures_info["engine:missing"],
light_weights: light_weights(quad.face, quad.positions), 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 // region has to be filled and lit first
// values is the middle chunk's blocks with their states, for models that change with them // 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[]) { 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 // for sorting the translucent quads
let centers = new Float32Array(256); let centers = new Float32Array(256);
let faces = new Uint8Array(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 block_info = blocks_registry[block_nid];
const layer_id = block_layers[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 alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1;
const wx = chunk_x * CHUNK_SIZE + x; 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); 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 // 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); centers = ensure_capacity(centers, (q + 1) * 3);
faces = ensure_capacity(faces, q + 1); faces = ensure_capacity(faces, q + 1);
const p = quad.positions; 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) => ({ // each layer's groups one after another, in one buffer
vertices: layer.vertices, const [solid, cutout] = opaque.map((groups) => {
quad_count: layer.floats / FLOATS_PER_QUAD, 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 quads = { centers, faces, count: translucent.quad_count };
const sort_type = choose_sort_type(quads); const sort_type = choose_sort_type(quads);
const [camera_x, camera_y, camera_z] = camera; const [camera_x, camera_y, camera_z] = camera;
return { return {
min_y: mesh_min_y === Infinity ? 0 : mesh_min_y,
max_y: mesh_max_y === -Infinity ? 0 : mesh_max_y,
solid, solid,
cutout, cutout,
translucent: { translucent: {
+10 -5
View File
@@ -180,6 +180,9 @@ export interface BakedQuad {
cull: number; cull: number;
// on the block's edge facing straight out, so it's lit like a full block's face // on the block's edge facing straight out, so it's lit like a full block's face
flush: boolean; 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; 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; let cull = face_json.cullface ? FACE_INDEX[face_json.cullface] : -1;
for (let t = 0; t < turns && cull >= 0; t++) cull = TURN_Y[cull]; 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({ quads.push({
positions, positions,
uvs, uvs,
@@ -253,6 +256,7 @@ export function bake_model(model: ModelJson, textures: BlockTextures | undefined
face: facing, face: facing,
cull, cull,
flush, flush,
aligned,
shade: element.shade ?? true, 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 // 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 } { 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 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 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]]; 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 axis = FACE_NORMALS[face].findIndex((v) => v !== 0);
const edge = FACE_NORMALS[face][axis] > 0 ? 1 : 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); const aligned = [1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - p[axis]) < EPSILON);
return { face, flush }; const flush = aligned && Math.abs(p[axis] - edge) < EPSILON;
return { face, flush, aligned };
} }
+135
View File
@@ -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<BlockRegistry>("blocks");
const block_ids: Record<string, number> = {};
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<string, (message: any) => 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<any>((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<string, Uint32Array>();
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`);
});