From f8d406dcf0a79be6c52feb34fcad7ca09ea21361 Mon Sep 17 00:00:00 2001 From: paulaboks Date: Fri, 25 Sep 2026 15:01:03 -0300 Subject: [PATCH] Fix transparent blocks --- MODS.md | 5 +- client/components/dimension.ts | 124 ++++++++++-- client/renderer/core.ts | 138 ++++++++++++-- client/systems/render_system.ts | 11 +- client/systems/rendering/dimension.ts | 50 +++-- client/workers/chunk_messages.ts | 49 ++++- client/workers/chunk_worker.ts | 263 ++++++++++++++------------ client/workers/translucent_sort.ts | 158 ++++++++++++++++ common/everything_registry.ts | 10 +- common/mod_data.ts | 23 ++- mods/bworld/blocks/glass.json | 2 +- mods/bworld/blocks/leaves.json | 2 +- mods/bworld/blocks/water.json | 2 +- 13 files changed, 645 insertions(+), 192 deletions(-) create mode 100644 client/workers/translucent_sort.ts diff --git a/MODS.md b/MODS.md index c322bd3..3af766d 100644 --- a/MODS.md +++ b/MODS.md @@ -195,8 +195,9 @@ program**, so the server and each client can number blocks differently. Saves an | ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- | | `id` | required | `id` | The block's id. | | `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. | -| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. | -| `alpha` | `1` | `alpha` | Opacity for transparent blocks. | +| `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. | +| `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. | +| `alpha` | `1` | `alpha` | Opacity for translucent blocks. | | `collision` | `true` | `has_collision` | Whether entities collide with it. | | `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. | | `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. | diff --git a/client/components/dimension.ts b/client/components/dimension.ts index b7bb219..403a049 100644 --- a/client/components/dimension.ts +++ b/client/components/dimension.ts @@ -1,14 +1,15 @@ import { Component } from "$/common/ecs/mod.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import { 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 { ClientWorld } from "../client_world.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts"; import { worldgen_mods } from "../mods.ts"; import type { FromChunkWorker } from "../workers/chunk_messages.ts"; -import { create_vertex_buffer, destroy_vertex_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 { Camera } from "./camera.ts"; export interface Block { @@ -28,12 +29,32 @@ export interface Chunk { dirty: boolean; // bumped on every mesh request so late results from older requests get ignored mesh_version: number; - opaque_vertex_buffer?: GPUBuffer; - opaque_vertex_count?: number; - transparent_vertex_buffer?: GPUBuffer; - transparent_vertex_count?: 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 * 9; + export { chunk_key }; const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; @@ -92,6 +113,7 @@ export class Dimension extends Component { dirty: true, generated: false, mesh_version: 0, + meshes: {}, }; this.chunks.set(chunk_key(x, z), chunk); return chunk; @@ -294,7 +316,7 @@ export class Dimension extends Component { } // sends every dirty chunk that can be meshed to the workers - request_meshes() { + request_meshes(camera: Camera) { for (const chunk of this.chunks.values()) { if (!chunk.dirty || !this.can_mesh(chunk)) { continue; @@ -308,15 +330,41 @@ export class Dimension extends Component { chunk_z: chunk.z, version: chunk.mesh_version, padded_chunk, + camera: [camera.x, camera.y, camera.z], }, [padded_chunk.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 { + } else if (message.type === "meshed") { this.#on_meshed(message); + } else { + this.#on_sorted(message); } } @@ -370,13 +418,47 @@ export class Dimension extends Component { this.delete_chunk_mesh(chunk); - chunk.opaque_vertex_buffer = create_vertex_buffer(message.opaque_vertices.subarray(0, message.opaque_count)); - chunk.opaque_vertex_count = message.opaque_count / 9; + 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, + }; + } - chunk.transparent_vertex_buffer = create_vertex_buffer( - message.transparent_vertices.subarray(0, message.transparent_count), - ); - chunk.transparent_vertex_count = message.transparent_count / 9; + 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. @@ -398,14 +480,14 @@ export class Dimension extends Component { } delete_chunk_mesh(chunk: Chunk) { - if (chunk.opaque_vertex_buffer) { - destroy_vertex_buffer(chunk.opaque_vertex_buffer); - chunk.opaque_vertex_buffer = undefined; - } - if (chunk.transparent_vertex_buffer) { - destroy_vertex_buffer(chunk.transparent_vertex_buffer); - chunk.transparent_vertex_buffer = undefined; + 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; } get_looked_block( diff --git a/client/renderer/core.ts b/client/renderer/core.ts index efe69fc..ad38584 100644 --- a/client/renderer/core.ts +++ b/client/renderer/core.ts @@ -1,5 +1,6 @@ import { Camera } from "../components/camera.ts"; import { mat4 } from "gl-matrix"; +import type { RenderLayer } from "$/common/everything_registry.ts"; export let device: GPUDevice; export let canvas: HTMLCanvasElement; @@ -20,6 +21,10 @@ let canvas_format: GPUTextureFormat; let pipeline_2d: GPURenderPipeline; let pipeline_3d: GPURenderPipeline; +let terrain_pipelines: Record; +// 0 1 2 0 2 3 for every quad, shared by all solid and cutout chunk meshes +let quad_index_buffer: GPUBuffer | undefined; +let quad_index_capacity = 0; let uniform_layout: GPUBindGroupLayout; let texture_layout: GPUBindGroupLayout; let sampler: GPUSampler; @@ -90,9 +95,30 @@ fn vs_main( return out; } +// blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { - return textureSample(texture0, sampler0, in.tex_coord) * in.color; + let color = textureSample(texture0, sampler0, in.tex_coord) * in.color; + if (color.a < 0.01) { + discard; + } + return color; +} + +// no discard, so the gpu can reject hidden fragments before running the shader +@fragment +fn fs_solid(in: VertexOut) -> @location(0) vec4 { + return vec4((textureSample(texture0, sampler0, in.tex_coord) * in.color).rgb, 1.0); +} + +// alpha tested instead of blended, so it doesn't need sorting +@fragment +fn fs_cutout(in: VertexOut) -> @location(0) vec4 { + let color = textureSample(texture0, sampler0, in.tex_coord) * in.color; + if (color.a < 0.1) { + discard; + } + return vec4(color.rgb, 1.0); } `; @@ -251,8 +277,33 @@ export function flush_batch() { vert_index = 0; } -export function flush_buffer(buffer: GPUBuffer, draw_count: number) { - draw(buffer, 0, draw_count); +// draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order +export function draw_terrain( + layer: RenderLayer, + vertex_buffer: GPUBuffer, + quad_count: number, + index_buffer?: GPUBuffer, +) { + if (!current_texture || quad_count === 0) { + return; + } + if (!index_buffer) { + ensure_quad_indices(quad_count); + index_buffer = quad_index_buffer!; + } + + const render_pass = ensure_pass(); + const pipeline = terrain_pipelines[layer]; + if (current_pipeline !== pipeline) { + render_pass.setPipeline(pipeline); + current_pipeline = pipeline; + } + + render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]); + render_pass.setBindGroup(1, get_texture_bind_group(current_texture)); + render_pass.setVertexBuffer(0, vertex_buffer); + render_pass.setIndexBuffer(index_buffer, "uint32"); + render_pass.drawIndexed(quad_count * 6); } export function create_vertex_buffer(vertices: Float32Array): GPUBuffer { @@ -264,7 +315,16 @@ export function create_vertex_buffer(vertices: Float32Array): GPUBuffer { return buffer; } -export function destroy_vertex_buffer(buffer: GPUBuffer) { +export function create_index_buffer(indices: Uint32Array): GPUBuffer { + const buffer = device.createBuffer({ + size: Math.max(4, indices.byteLength), + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(buffer, 0, indices); + return buffer; +} + +export function destroy_buffer(buffer: GPUBuffer) { // it might be used by a draw thats not submitted yet if (encoder) { pending_destroy.push(buffer); @@ -375,6 +435,28 @@ export function push_quad_vertices( // internal +function ensure_quad_indices(quad_count: number) { + if (quad_count <= quad_index_capacity) { + return; + } + if (quad_index_buffer) { + destroy_buffer(quad_index_buffer); + } + quad_index_capacity = Math.max(quad_count, quad_index_capacity * 2, 16384); + const indices = new Uint32Array(quad_index_capacity * 6); + for (let q = 0; q < quad_index_capacity; q++) { + const v = q * 4; + const i = q * 6; + indices[i] = v; + indices[i + 1] = v + 1; + indices[i + 2] = v + 2; + indices[i + 3] = v; + indices[i + 4] = v + 2; + indices[i + 5] = v + 3; + } + quad_index_buffer = create_index_buffer(indices); +} + function draw(buffer: GPUBuffer, offset: number, vertex_count: number) { if (!current_texture || vertex_count === 0) { return; @@ -554,21 +636,51 @@ function create_pipelines() { depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" }, }); + const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" }; + const depth_3d: GPUDepthStencilState = { + format: DEPTH_FORMAT, + depthWriteEnabled: true, + depthCompare: "less-equal", + // same as the old polygonOffset(1, 1) + depthBias: 1, + depthBiasSlopeScale: 1, + }; + pipeline_3d = device.createRenderPipeline({ layout, vertex, fragment, multisample, - primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" }, - depthStencil: { - format: DEPTH_FORMAT, - depthWriteEnabled: true, - depthCompare: "less-equal", - // same as the old polygonOffset(1, 1) - depthBias: 1, - depthBiasSlopeScale: 1, - }, + primitive: primitive_3d, + depthStencil: depth_3d, }); + + // like sodium and vanilla: solid and cutout don't blend, translucent blends and still writes depth + // since it's drawn sorted back to front + const unblended = (entryPoint: string): GPUFragmentState => ({ + module, + entryPoint, + targets: [{ format: canvas_format }], + }); + terrain_pipelines = { + solid: device.createRenderPipeline({ + layout, + vertex, + fragment: unblended("fs_solid"), + multisample, + primitive: primitive_3d, + depthStencil: depth_3d, + }), + cutout: device.createRenderPipeline({ + layout, + vertex, + fragment: unblended("fs_cutout"), + multisample, + primitive: primitive_3d, + depthStencil: depth_3d, + }), + translucent: pipeline_3d, + }; } function create_stream_buffer(size: number) { diff --git a/client/systems/render_system.ts b/client/systems/render_system.ts index 740f469..83be7c1 100644 --- a/client/systems/render_system.ts +++ b/client/systems/render_system.ts @@ -5,7 +5,7 @@ import { Dimension } from "../components/dimension.ts"; import { Camera } from "$/client/components/camera.ts"; import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts"; -import { render_dimension } from "./rendering/dimension.ts"; +import { render_dimension_opaque, render_dimension_translucent } from "./rendering/dimension.ts"; import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts"; import { PlayerComponent } from "../player.ts"; import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts"; @@ -31,7 +31,7 @@ export class RenderSystem extends System { const dimension = entity.get(Dimension); if (dimension) { - render_dimension(dimension, camera); + render_dimension_opaque(dimension, camera); } const player_component = entity.get(PlayerComponent); @@ -44,6 +44,13 @@ export class RenderSystem extends System { render_remote_players(world.connection); } + for (const entity of world.get_entities()) { + const dimension = entity.get(Dimension); + if (dimension) { + render_dimension_translucent(dimension, camera); + } + } + end_mode_3d(); for (const entity of world.get_entities()) { diff --git a/client/systems/rendering/dimension.ts b/client/systems/rendering/dimension.ts index 9baae37..d9484a1 100644 --- a/client/systems/rendering/dimension.ts +++ b/client/systems/rendering/dimension.ts @@ -1,33 +1,45 @@ -import { Chunk, Dimension } from "$/client/components/dimension.ts"; +import { CHUNK_SIZE, Dimension } from "$/client/components/dimension.ts"; import { Camera } from "$/client/components/camera.ts"; -import { flush_buffer, set_current_texture } from "$/client/renderer/mod.ts"; +import { draw_terrain, set_current_texture } from "$/client/renderer/mod.ts"; -export function render_dimension(dimension: Dimension, _camera: Camera) { - dimension.request_meshes(); +// solid and cutout terrain, drawn before entities +export function render_dimension_opaque(dimension: Dimension, camera: Camera) { + dimension.request_meshes(camera); + dimension.update_translucent_sorting(camera); set_current_texture(dimension.image.tex); for (const chunk of dimension.chunks.values()) { - render_chunk_opaque(chunk); + const mesh = chunk.meshes.solid; + if (mesh) { + draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count); + } } for (const chunk of dimension.chunks.values()) { - render_chunk_transparent(chunk); + const mesh = chunk.meshes.cutout; + if (mesh) { + draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count); + } } } -function render_chunk_opaque(chunk: Chunk) { - if (!chunk.opaque_vertex_buffer || !chunk.opaque_vertex_count) { - return; +// 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 +export function render_dimension_translucent(dimension: Dimension, 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 = [...dimension.chunks.values()] + .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(dimension.image.tex); + + for (const { mesh } of chunks) { + draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer); } - - flush_buffer(chunk.opaque_vertex_buffer, chunk.opaque_vertex_count); -} - -function render_chunk_transparent(chunk: Chunk) { - if (!chunk.transparent_vertex_buffer || !chunk.transparent_vertex_count) { - return; - } - - flush_buffer(chunk.transparent_vertex_buffer, chunk.transparent_vertex_count); } diff --git a/client/workers/chunk_messages.ts b/client/workers/chunk_messages.ts index 709f30f..47d7e1c 100644 --- a/client/workers/chunk_messages.ts +++ b/client/workers/chunk_messages.ts @@ -1,6 +1,7 @@ import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { SpriteRegion } from "$/common/constants.ts"; import type { OreJson } from "$/common/mod_data.ts"; +import type { SortType } from "./translucent_sort.ts"; // messages between the main thread and the chunk workers @@ -17,7 +18,30 @@ export type ToChunkWorker = ores: OreJson[]; } | { type: "generate"; chunk_x: number; chunk_z: number; seed: string } - | { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array }; + | { + type: "mesh"; + chunk_x: number; + chunk_z: number; + version: number; + padded_chunk: Uint32Array; + // where the camera is, to sort the translucent quads + camera: number[]; + } + | { + type: "sort"; + chunk_x: number; + chunk_z: number; + version: number; + sort_version: number; + centers: Float32Array; + camera: number[]; + }; + +// 4 vertices per quad +export interface LayerMesh { + vertices: Float32Array; + quad_count: number; +} export type FromChunkWorker = | { @@ -33,8 +57,23 @@ export type FromChunkWorker = chunk_x: number; chunk_z: number; version: number; - opaque_vertices: Float32Array; - opaque_count: number; - transparent_vertices: Float32Array; - transparent_count: number; + solid: LayerMesh; + cutout: LayerMesh; + translucent: LayerMesh & { + // sorted back to front for the camera the mesh was requested with + indices: Uint32Array; + sort_type: SortType; + // what resorting needs, see translucent_sort.ts + centers: Float32Array; + planes: [Float32Array, Float32Array, Float32Array]; + }; + camera: number[]; + } + | { + type: "sorted"; + chunk_x: number; + chunk_z: number; + version: number; + sort_version: number; + indices: Uint32Array; }; diff --git a/client/workers/chunk_worker.ts b/client/workers/chunk_worker.ts index 8b557d6..d6ae3c6 100644 --- a/client/workers/chunk_worker.ts +++ b/client/workers/chunk_worker.ts @@ -1,12 +1,20 @@ /// -import type { BlockRegistry } from "$/common/everything_registry.ts"; -import type { SpriteRegion } from "$/common/constants.ts"; +import type { BlockRegistry, RenderLayer } from "$/common/everything_registry.ts"; +import { AIR, type SpriteRegion } from "$/common/constants.ts"; import type { Texture } from "../renderer/types.ts"; import type { FromChunkWorker, ToChunkWorker } 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 } from "$/common/utils.ts"; +import { + choose_sort_type, + FACE_NORMALS, + quad_indices, + quad_planes, + sort_by_distance, + sort_quads, +} from "./translucent_sort.ts"; const pad = 0.5; @@ -64,9 +72,6 @@ export function push_front_face( i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y, z2, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x, y2, z2, u0, v0, r, g, b, a); return i; @@ -99,9 +104,6 @@ export function push_back_face( i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x, y, z, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z, u0, v0, r, g, b, a); return i; @@ -134,9 +136,6 @@ export function push_left_face( i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x, y, z2, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a); return i; @@ -170,9 +169,6 @@ export function push_right_face( i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z2, u0, v0, r, g, b, a); return i; @@ -206,9 +202,6 @@ export function push_top_face( i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z2, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a); return i; @@ -241,9 +234,6 @@ export function push_bottom_face( i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a); i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a); - - i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a); - i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a); i = push_vertex(vertices, i, x, y, z2, u0, v0, r, g, b, a); return i; @@ -254,18 +244,29 @@ type TexturesInfo = Record; const CHUNK_SIZE = 16; const CHUNK_HEIGHT = 128; const TEXTURE_SIZE = 16; +const FLOATS_PER_QUAD = 4 * 9; -const FACE_PUSHING_FUNCTIONS = { - top: push_top_face, - bottom: push_bottom_face, - front: push_front_face, - back: push_back_face, - left: push_left_face, - right: push_right_face, -} as const; +// same order as FACE_NORMALS in translucent_sort.ts +const FACES = ["top", "bottom", "front", "back", "left", "right"] as const; +const FACE_PUSHING_FUNCTIONS = [ + push_top_face, + push_bottom_face, + push_front_face, + push_back_face, + push_left_face, + push_right_face, +] as const; + +const SOLID = 0; +const CUTOUT = 1; +const TRANSLUCENT = 2; +const LAYER_IDS: Record = { solid: SOLID, cutout: CUTOUT, translucent: TRANSLUCENT }; let blocks_registry: BlockRegistry[] = []; let block_ids: Record = {}; +// by numeric id, checked for every face +let block_layers = new Uint8Array(0); +let block_cull_same = new Uint8Array(0); let textures_info: TexturesInfo = {}; let image: Texture; let worldgen: WorldgenSetup | undefined; @@ -280,6 +281,11 @@ self.onmessage = async (event: MessageEvent) => { case "init": blocks_registry = message.blocks_registry; block_ids = message.block_ids; + block_layers = Uint8Array.from(blocks_registry, (block) => LAYER_IDS[block?.render_layer ?? "solid"]); + block_cull_same = Uint8Array.from( + blocks_registry, + (block) => (block?.cull_same ?? block?.render_layer === "translucent") ? 1 : 0, + ); textures_info = message.textures_info; image = message.image as Texture; default_values = blocks_registry.map((block, nid) => default_block_value(nid, block)); @@ -292,13 +298,11 @@ self.onmessage = async (event: MessageEvent) => { generate(message.chunk_x, message.chunk_z, message.seed); break; case "mesh": { - const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh( + const { solid, cutout, translucent } = make_chunk_mesh( message.chunk_x, message.chunk_z, message.padded_chunk, - blocks_registry, - textures_info, - image, + message.camera, ); post( { @@ -306,15 +310,35 @@ self.onmessage = async (event: MessageEvent) => { chunk_x: message.chunk_x, chunk_z: message.chunk_z, version: message.version, - opaque_vertices, - opaque_count, - transparent_vertices, - transparent_count, + solid, + cutout, + translucent, + camera: message.camera, }, - [opaque_vertices.buffer, transparent_vertices.buffer], + [ + solid.vertices.buffer, + cutout.vertices.buffer, + translucent.vertices.buffer, + translucent.indices.buffer, + translucent.centers.buffer, + ...translucent.planes.map((planes) => planes.buffer), + ], ); break; } + case "sort": { + const [x, y, z] = message.camera; + const indices = quad_indices(sort_by_distance(message.centers, message.centers.length / 3, x, y, z)); + post({ + type: "sorted", + chunk_x: message.chunk_x, + chunk_z: message.chunk_z, + version: message.version, + sort_version: message.sort_version, + indices, + }, [indices.buffer]); + break; + } } }; @@ -327,42 +351,38 @@ function generate(chunk_x: number, chunk_z: number, seed: string) { post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]); } -function make_chunk_mesh( - chunk_x: number, - chunk_z: number, - padded_chunk: Uint32Array, - blocks_registry: BlockRegistry[], - textures_info: TexturesInfo, - image: Texture, -): [Float32Array, number, Float32Array, number] { - let opaque_vertices = new Float32Array(2048); - let opaque_count = 0; - let transparent_vertices = new Float32Array(2048); - let transparent_count = 0; +// the rule vanilla minecraft (and so sodium) uses: solid neighbors hide a face, and some blocks +// hide faces between two of themselves +function show_face(block: number, neighbor: number) { + if (neighbor === AIR) return true; + // unloaded (VOID) or above/below the world + const layer = block_layers[neighbor]; + if (layer === undefined || layer === SOLID) return false; + return !(neighbor === block && block_cull_same[block]); +} + +function make_chunk_mesh(chunk_x: number, chunk_z: number, padded_chunk: Uint32Array, camera: number[]) { + const layers = [SOLID, CUTOUT, TRANSLUCENT].map(() => ({ vertices: new Float32Array(2048), floats: 0 })); + // for sorting the translucent quads + let centers = new Float32Array(256); + let faces = new Uint8Array(256); const size = CHUNK_SIZE + 2; - const layer = size * size; - - const padded_index = (x: number, y: number, z: number) => { - return y * layer + z * size + x; - }; - - const show_face = (block: number) => { - if (block === 0) return true; - const info = blocks_registry[block]; - return info?.transparent ?? false; - }; + const layer_size = size * size; + // where the neighbor on each face is in padded_chunk, same order as FACES + const face_offsets = [layer_size, -layer_size, size, -size, -1, 1]; for (let y = 0; y < CHUNK_HEIGHT; y++) { for (let z = 0; z < CHUNK_SIZE; z++) { for (let x = 0; x < CHUNK_SIZE; x++) { - const px = x + 1; - const pz = z + 1; - - const block_nid = padded_chunk[padded_index(px, y, pz)]; - if (block_nid === 0) continue; + const index = y * layer_size + (z + 1) * size + (x + 1); + const block_nid = padded_chunk[index]; + if (block_nid === AIR) continue; 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 texture_ids = { top: "engine:missing", @@ -402,72 +422,71 @@ function make_chunk_mesh( const wx = chunk_x * CHUNK_SIZE + x; const wz = chunk_z * CHUNK_SIZE + z; - const faces = { - front: show_face(padded_chunk[padded_index(px, y, pz + 1)]), - back: show_face(padded_chunk[padded_index(px, y, pz - 1)]), - left: show_face(padded_chunk[padded_index(px - 1, y, pz)]), - right: show_face(padded_chunk[padded_index(px + 1, y, pz)]), - top: show_face(padded_chunk[padded_index(px, y + 1, pz)]), - bottom: show_face(padded_chunk[padded_index(px, y - 1, pz)]), - } as const; + for (let face = 0; face < 6; face++) { + if (!show_face(block_nid, padded_chunk[index + face_offsets[face]])) { + continue; + } - for (const side of ["front", "back", "left", "right", "top", "bottom"] as const) { - if (faces[side]) { - const region = textures_info[texture_ids[side]]; + const region = textures_info[texture_ids[FACES[face]]]; + layer.vertices = ensure_capacity(layer.vertices, layer.floats + FLOATS_PER_QUAD); + layer.floats = FACE_PUSHING_FUNCTIONS[face]( + layer.vertices, + layer.floats, + image, + wx, + y, + wz, + region.x * TEXTURE_SIZE, + region.y * TEXTURE_SIZE, + TEXTURE_SIZE, + TEXTURE_SIZE, + 1, + 1, + 1, + alpha, + ); - if (block_info.transparent) { - transparent_vertices = ensure_capacity( - transparent_vertices, - transparent_count + (6 * 6 * 9), - ); - transparent_count = FACE_PUSHING_FUNCTIONS[side]( - transparent_vertices, - transparent_count, - image, - wx, - y, - wz, - region.x * TEXTURE_SIZE, - region.y * TEXTURE_SIZE, - TEXTURE_SIZE, - TEXTURE_SIZE, - 1, - 1, - 1, - block_info.alpha ?? 1, - ); - } else { - opaque_vertices = ensure_capacity(opaque_vertices, opaque_count + (6 * 6 * 9)); - opaque_count = FACE_PUSHING_FUNCTIONS[side]( - opaque_vertices, - opaque_count, - image, - wx, - y, - wz, - region.x * TEXTURE_SIZE, - region.y * TEXTURE_SIZE, - TEXTURE_SIZE, - TEXTURE_SIZE, - 1, - 1, - 1, - 1, - ); - } + if (layer_id === TRANSLUCENT) { + const quad = layer.floats / FLOATS_PER_QUAD - 1; + centers = ensure_capacity(centers, (quad + 1) * 3); + faces = ensure_capacity(faces, quad + 1); + const [nx, ny, nz] = FACE_NORMALS[face]; + centers[quad * 3] = wx + 0.5 + nx * 0.5; + centers[quad * 3 + 1] = y + 0.5 + ny * 0.5; + centers[quad * 3 + 2] = wz + 0.5 + nz * 0.5; + faces[quad] = face; } } } } } - return [opaque_vertices, opaque_count, transparent_vertices, transparent_count]; + const [solid, cutout, translucent] = layers.map((layer) => ({ + vertices: layer.vertices, + quad_count: layer.floats / FLOATS_PER_QUAD, + })); + + const quads = { centers, faces, count: translucent.quad_count }; + const sort_type = choose_sort_type(quads); + const [camera_x, camera_y, camera_z] = camera; + + return { + solid, + cutout, + translucent: { + ...translucent, + indices: sort_quads(quads, sort_type, camera_x, camera_y, camera_z), + sort_type, + centers: centers.slice(0, quads.count * 3), + planes: quad_planes(quads), + }, + }; } -function ensure_capacity( - buffer: Float32Array, +function ensure_capacity | Uint8Array>( + buffer: T, required: number, -) { +): T { if (required <= buffer.length) return buffer; let new_length = buffer.length; @@ -475,7 +494,7 @@ function ensure_capacity( new_length *= 2; } - const new_buffer = new Float32Array(new_length); - new_buffer.set(buffer); + const new_buffer = new (buffer.constructor as new (length: number) => T)(new_length); + new_buffer.set(buffer as ArrayLike); return new_buffer; } diff --git a/client/workers/translucent_sort.ts b/client/workers/translucent_sort.ts new file mode 100644 index 0000000..457f973 --- /dev/null +++ b/client/workers/translucent_sort.ts @@ -0,0 +1,158 @@ +// translucent quad sorting, the way sodium does it (its "translucency sorting"), simplified for quads +// that all face along an axis. +// every chunk's translucent mesh gets a sort type when it's meshed: +// - none: the order can't matter, one quad or all of them in a single plane +// - static: all quads face along one axis, so sorting them by distance along their normal once is right +// from anywhere the camera can see them +// - dynamic: sorted by distance to the camera, and sorted again only when the camera crosses one of the +// planes the chunk's quads lie on, the only time the order between them can change (sodium's GFNI) + +export type SortType = "none" | "static" | "dynamic"; + +// same order as the mesher's faces +export const FACE_NORMALS = [ + [0, 1, 0], // top + [0, -1, 0], // bottom + [0, 0, 1], // front + [0, 0, -1], // back + [-1, 0, 0], // left + [1, 0, 0], // right +] as const; + +export const FACE_AXIS = [1, 1, 2, 2, 0, 0] as const; + +// for each quad: its center, and which face it is +export interface TranslucentQuads { + centers: Float32Array; + faces: Uint8Array; + count: number; +} + +export function choose_sort_type(quads: TranslucentQuads): SortType { + if (quads.count <= 1) { + return "none"; + } + + let axes = 0; + for (let q = 0; q < quads.count; q++) { + axes |= 1 << FACE_AXIS[quads.faces[q]]; + } + // more than one axis, the order depends on where the camera is + if (axes & (axes - 1)) { + return "dynamic"; + } + + // quads facing opposite ways on the same axis are never both visible over each other, so only + // the distance along the axis matters. if they all share a plane, nothing can overlap at all + const axis = FACE_AXIS[quads.faces[0]]; + const plane = quads.centers[axis]; + for (let q = 1; q < quads.count; q++) { + if (quads.centers[q * 3 + axis] !== plane) { + return "static"; + } + } + return "none"; +} + +// the unique coordinates of the planes the quads lie on, per axis, sorted. the camera crossing one of +// these is what triggers a dynamic sort +export function quad_planes(quads: TranslucentQuads): [Float32Array, Float32Array, Float32Array] { + const sets = [new Set(), new Set(), new Set()]; + for (let q = 0; q < quads.count; q++) { + const axis = FACE_AXIS[quads.faces[q]]; + sets[axis].add(quads.centers[q * 3 + axis]); + } + return sets.map((set) => Float32Array.from(set).sort()) as [Float32Array, Float32Array, Float32Array]; +} + +// whether moving the camera from a to b crosses any of the planes +export function crosses_planes(planes: [Float32Array, Float32Array, Float32Array], a: number[], b: number[]) { + for (let axis = 0; axis < 3; axis++) { + if (a[axis] === b[axis] || planes[axis].length === 0) { + continue; + } + if (count_below(planes[axis], a[axis]) !== count_below(planes[axis], b[axis])) { + return true; + } + } + return false; +} + +function count_below(sorted: Float32Array, value: number) { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sorted[mid] < value) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} + +// back to front, for the camera at camera_x/y/z when the sort type is dynamic +export function sort_quads( + quads: TranslucentQuads, + sort_type: SortType, + camera_x: number, + camera_y: number, + camera_z: number, +): Uint32Array { + const { centers, faces, count } = quads; + if (sort_type === "dynamic") { + return quad_indices(sort_by_distance(centers, count, camera_x, camera_y, camera_z)); + } + + const order = new Uint32Array(count); + for (let q = 0; q < count; q++) { + order[q] = q; + } + if (sort_type === "static") { + // for quads facing the camera, the ones further along their normal are closer to it + const keys = new Float32Array(count); + for (let q = 0; q < count; q++) { + const [nx, ny, nz] = FACE_NORMALS[faces[q]]; + keys[q] = centers[q * 3] * nx + centers[q * 3 + 1] * ny + centers[q * 3 + 2] * nz; + } + order.sort((a, b) => keys[a] - keys[b]); + } + return quad_indices(order); +} + +// dynamic sorting only needs the centers, so resorting doesn't need the whole mesh +export function sort_by_distance( + centers: Float32Array, + count: number, + camera_x: number, + camera_y: number, + camera_z: number, +): Uint32Array { + const order = new Uint32Array(count); + const distances = new Float32Array(count); + for (let q = 0; q < count; q++) { + order[q] = q; + const dx = centers[q * 3] - camera_x; + const dy = centers[q * 3 + 1] - camera_y; + const dz = centers[q * 3 + 2] - camera_z; + distances[q] = dx * dx + dy * dy + dz * dz; + } + return order.sort((a, b) => distances[b] - distances[a]); +} + +// two triangles per quad, drawn in the given order +export function quad_indices(order: Uint32Array): Uint32Array { + const indices = new Uint32Array(order.length * 6); + for (let i = 0; i < order.length; i++) { + const v = order[i] * 4; + const o = i * 6; + indices[o] = v; + indices[o + 1] = v + 1; + indices[o + 2] = v + 2; + indices[o + 3] = v; + indices[o + 4] = v + 2; + indices[o + 5] = v + 3; + } + return indices; +} diff --git a/common/everything_registry.ts b/common/everything_registry.ts index 0071d97..6f414c9 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -93,10 +93,18 @@ interface BlockStateVariant { y: number; } +// solid: fully opaque. cutout: texels are either opaque or see-through (leaves). +// translucent: blended and sorted back to front (water, glass) +export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const; +export type RenderLayer = typeof RENDER_LAYERS[number]; + export interface BlockRegistry { id: string; textures: string | TextureSideTopBottom | TextureFront; - transparent?: boolean; + // solid when not set. anything else doesn't hide its neighbors' faces + render_layer?: RenderLayer; + // hide faces between two of this block, like glass and water. defaults to true for translucent blocks + cull_same?: boolean; alpha?: number; has_collision: boolean; diff --git a/common/mod_data.ts b/common/mod_data.ts index 6335197..0cd4c7a 100644 --- a/common/mod_data.ts +++ b/common/mod_data.ts @@ -1,6 +1,12 @@ // the json formats from MODS.md, and converting them to and from the engine's registry entries. // the mod loader uses the from_json direction, tests use both to check nothing is lost -import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts"; +import { + type BlockRegistry, + type BlockStateDefinition, + type ItemRegistry, + RENDER_LAYERS, + type RenderLayer, +} from "./everything_registry.ts"; export const FORMAT_VERSION = 1; export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; @@ -25,6 +31,9 @@ export interface ManifestJson { export interface BlockJson { id: string; textures: BlockTextures; + render_layer?: RenderLayer; + cull_same?: boolean; + // replaced by render_layer, true means translucent transparent?: boolean; alpha?: number; collision?: boolean; @@ -78,7 +87,8 @@ export interface GridRecipe { export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { const json: BlockJson = { id: block.id, textures: block.textures }; - if (block.transparent) json.transparent = true; + if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer; + if (block.cull_same !== undefined) json.cull_same = block.cull_same; if (block.alpha !== undefined) json.alpha = block.alpha; if (!block.has_collision) json.collision = false; if (block.toughness !== undefined) { @@ -101,7 +111,9 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it textures: json.textures, has_collision: json.collision ?? true, }; - if (json.transparent) block.transparent = true; + const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined); + if (render_layer && render_layer !== "solid") block.render_layer = render_layer; + if (json.cull_same !== undefined) block.cull_same = json.cull_same; if (json.alpha !== undefined) block.alpha = json.alpha; if (json.mining) { block.toughness = json.mining.toughness; @@ -243,7 +255,10 @@ export function validate_block(json: unknown): Problems { (["top", "bottom", "side"].every((k) => typeof textures[k] === "string") || ["front", "side"].every((k) => typeof textures[k] === "string"))); if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }"); - for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) { + if (json.render_layer !== undefined && !RENDER_LAYERS.includes(json.render_layer as RenderLayer)) { + problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`); + } + for (const key of ["cull_same", "transparent", "collision", "item", "interactive", "replaceable"]) { if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`); } if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) { diff --git a/mods/bworld/blocks/glass.json b/mods/bworld/blocks/glass.json index 9213c37..279cfaf 100644 --- a/mods/bworld/blocks/glass.json +++ b/mods/bworld/blocks/glass.json @@ -3,7 +3,7 @@ "block": { "id": "bworld:glass", "textures": "bworld:glass", - "transparent": true, + "render_layer": "translucent", "mining": { "toughness": 3, "tool": "pickaxe" diff --git a/mods/bworld/blocks/leaves.json b/mods/bworld/blocks/leaves.json index 0be99d1..0dd1afa 100644 --- a/mods/bworld/blocks/leaves.json +++ b/mods/bworld/blocks/leaves.json @@ -3,7 +3,7 @@ "block": { "id": "bworld:leaves", "textures": "bworld:leaves", - "transparent": true, + "render_layer": "cutout", "mining": { "toughness": 3, "tool": "hoe" diff --git a/mods/bworld/blocks/water.json b/mods/bworld/blocks/water.json index 911eeb4..152a93f 100644 --- a/mods/bworld/blocks/water.json +++ b/mods/bworld/blocks/water.json @@ -3,7 +3,7 @@ "block": { "id": "bworld:water", "textures": "bworld:water", - "transparent": true, + "render_layer": "translucent", "alpha": 0.8, "collision": false, "item": false,