Files
bworld/client/renderer/core.ts
T

723 lines
19 KiB
TypeScript

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;
const MAX_SPRITES = 100000;
const VERTS_PER_SPRITE = 6;
const FLOATS_PER_VERT = 9;
const VERTEX_STRIDE = FLOATS_PER_VERT * 4;
const SAMPLE_COUNT = 4;
const DEPTH_FORMAT: GPUTextureFormat = "depth24plus";
// dynamic uniform offsets have to be 256 aligned
const UNIFORM_SLOT_SIZE = 256;
let context: GPUCanvasContext;
let canvas_format: GPUTextureFormat;
let pipeline_2d: GPURenderPipeline;
let pipeline_3d: GPURenderPipeline;
let terrain_pipelines: Record<RenderLayer, GPURenderPipeline>;
// 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;
const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT);
let vert_index = 0;
// every flush in a frame appends to this, so earlier draws dont get overwritten before the submit
let stream_buffer: GPUBuffer;
let stream_offset = 0;
let uniform_buffer: GPUBuffer;
let uniform_bind_group: GPUBindGroup;
let uniform_slot = -1;
const texture_bind_groups = new WeakMap<GPUTexture, GPUBindGroup>();
let current_texture: GPUTexture | null = null;
export let white_tex: GPUTexture | null = null;
let mode3d = false;
let camera: Camera | undefined;
const ortho = mat4.create();
const proj = mat4.create();
const view = mat4.create();
const mvp = mat4.create();
// per frame state
let encoder: GPUCommandEncoder | undefined;
let pass: GPURenderPassEncoder | undefined;
let frame_view: GPUTextureView;
let msaa_texture: GPUTexture | undefined;
let depth_texture: GPUTexture | undefined;
let current_pipeline: GPURenderPipeline | undefined;
let clear_color: GPUColor = { r: 0, g: 0, b: 0, a: 1 };
let pending_color_clear = false;
let pending_depth_clear = false;
let scissor: { x: number; y: number; width: number; height: number } | undefined;
let pending_destroy: GPUBuffer[] = [];
const shader_src = /* wgsl */ `
struct Uniforms {
mvp: mat4x4<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var texture0: texture_2d<f32>;
@group(1) @binding(1) var sampler0: sampler;
struct VertexOut {
@builtin(position) position: vec4<f32>,
// centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the
// sprite's edge in the atlas, which shows up as dark lines between blocks from far away
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
}
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) tex_coord: vec2<f32>,
@location(2) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
out.tex_coord = tex_coord;
out.color = color;
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<f32> {
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<f32> {
return vec4<f32>((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<f32> {
let color = textureSample(texture0, sampler0, in.tex_coord) * in.color;
if (color.a < 0.1) {
discard;
}
return vec4<f32>(color.rgb, 1.0);
}
`;
export async function init_window(canvas_element: HTMLCanvasElement) {
canvas = canvas_element;
canvas.width = 1800;
canvas.height = 900;
if (!navigator.gpu) {
throw new Error("WebGPU not supported");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No WebGPU adapter found");
}
device = await adapter.requestDevice();
device.addEventListener("uncapturederror", (event) => {
console.error("WebGPU error:", (event as GPUUncapturedErrorEvent).error.message);
});
device.lost.then((info) => console.error(`WebGPU device lost: ${info.message}`));
// the dom typings dont know about the webgpu overload yet
const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null;
if (!ctx) {
throw new Error("WebGPU not supported");
}
context = ctx;
canvas_format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format: canvas_format, alphaMode: "opaque" });
create_pipelines();
sampler = device.createSampler({
magFilter: "nearest",
minFilter: "nearest",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
stream_buffer = create_stream_buffer(8 * 1024 * 1024);
create_uniform_buffer(64);
create_white_texture();
}
export function begin_drawing() {
encoder = device.createCommandEncoder();
frame_view = context.getCurrentTexture().createView();
ensure_render_targets();
update_2d_mvp();
stream_offset = 0;
uniform_slot = -1;
write_mvp();
pending_color_clear = true;
pending_depth_clear = true;
vert_index = 0;
}
export function end_drawing() {
flush_batch();
// make sure the frame gets cleared even if nothing was drawn
ensure_pass();
pass!.end();
pass = undefined;
device.queue.submit([encoder!.finish()]);
encoder = undefined;
for (const buffer of pending_destroy) {
buffer.destroy();
}
pending_destroy = [];
}
function update_2d_mvp() {
const w = canvas.width;
const h = canvas.height;
const l = 0;
const r = w;
const t = 0;
const b = h;
const n = -1;
const f = 1;
mat4.orthoZO(ortho, l, r, b, t, n, f);
}
export function begin_clip(x: number, y: number, width: number, height: number) {
flush_batch();
scissor = { x, y, width, height };
if (pass) {
apply_scissor(pass);
}
}
export function end_clip() {
flush_batch();
scissor = undefined;
if (pass) {
apply_scissor(pass);
}
}
export function begin_mode_3d(new_camera: Camera) {
flush_batch();
mode3d = true;
camera = new_camera;
update_camera();
write_mvp();
}
export function end_mode_3d() {
if (!mode3d) {
return;
}
flush_batch();
mode3d = false;
write_mvp();
}
export function clear_background(r: number, g: number, b: number, a = 1) {
flush_batch();
clear_color = { r, g, b, a };
// the clear happens when a render pass starts, so start a new one
if (pass) {
pass.end();
pass = undefined;
}
pending_color_clear = true;
}
export function flush_batch() {
if (vert_index === 0 || !current_texture) {
return;
}
const byte_length = vert_index * 4;
if (stream_offset + byte_length > stream_buffer.size) {
// the old buffer might still be used by draws in this frame, destroy it after the submit
pending_destroy.push(stream_buffer);
stream_buffer = create_stream_buffer(Math.max(stream_buffer.size * 2, byte_length));
stream_offset = 0;
}
device.queue.writeBuffer(stream_buffer, stream_offset, vertex_data, 0, vert_index);
draw(stream_buffer, stream_offset, vert_index / FLOATS_PER_VERT);
stream_offset += byte_length;
vert_index = 0;
}
// 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 {
const buffer = device.createBuffer({
size: Math.max(4, vertices.byteLength),
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buffer, 0, vertices);
return buffer;
}
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);
} else {
buffer.destroy();
}
}
export function resize_canvas() {
const width = self.innerWidth;
const height = self.innerHeight;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
// render targets get recreated at the start of the next frame
}
}
export function get_current_texture(): GPUTexture | null {
return current_texture;
}
export function set_current_texture(texture: GPUTexture) {
current_texture = texture;
}
export function create_texture(width: number, height: number): GPUTexture {
return device.createTexture({
size: [width, height],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
});
}
export function push_vertex(px: number, py: number, pz: number, u: number, vv: number, r = 1, g = 1, b = 1, a = 1) {
let i = vert_index;
vertex_data[i++] = px;
vertex_data[i++] = py;
vertex_data[i++] = pz;
vertex_data[i++] = u;
vertex_data[i++] = vv;
vertex_data[i++] = r;
vertex_data[i++] = g;
vertex_data[i++] = b;
vertex_data[i++] = a;
vert_index = i;
}
export function push_quad(
dx: number,
dy: number,
dw: number,
dh: number,
u0: number,
v0: number,
u1: number,
v1: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = dx + dw;
const y2 = dy + dh;
// triangle 1
push_vertex(dx, dy, 0, u0, v0, r, g, b, a);
push_vertex(x2, dy, 0, u1, v0, r, g, b, a);
push_vertex(dx, y2, 0, u0, v1, r, g, b, a);
// triangle 2
push_vertex(x2, dy, 0, u1, v0, r, g, b, a);
push_vertex(x2, y2, 0, u1, v1, r, g, b, a);
push_vertex(dx, y2, 0, u0, v1, r, g, b, a);
}
export function push_quad_vertices(
x0: number,
y0: number,
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
u0: number,
v0: number,
u1: number,
v1: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
// triangle 1
push_vertex(x0, y0, 0, u0, v0, r, g, b, a);
push_vertex(x1, y1, 0, u1, v0, r, g, b, a);
push_vertex(x2, y2, 0, u1, v1, r, g, b, a);
// triangle 2
push_vertex(x0, y0, 0, u0, v0, r, g, b, a);
push_vertex(x2, y2, 0, u1, v1, r, g, b, a);
push_vertex(x3, y3, 0, u0, v1, r, g, b, a);
}
// 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;
}
const render_pass = ensure_pass();
const pipeline = mode3d ? pipeline_3d : pipeline_2d;
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, buffer, offset);
render_pass.draw(vertex_count);
}
function ensure_pass(): GPURenderPassEncoder {
if (pass) {
return pass;
}
pass = encoder!.beginRenderPass({
colorAttachments: [{
view: msaa_texture!.createView(),
resolveTarget: frame_view,
clearValue: clear_color,
loadOp: pending_color_clear ? "clear" : "load",
storeOp: "store",
}],
depthStencilAttachment: {
view: depth_texture!.createView(),
depthClearValue: 1,
depthLoadOp: pending_depth_clear ? "clear" : "load",
depthStoreOp: "store",
},
});
pending_color_clear = false;
pending_depth_clear = false;
current_pipeline = undefined;
apply_scissor(pass);
return pass;
}
function apply_scissor(render_pass: GPURenderPassEncoder) {
const target_width = msaa_texture!.width;
const target_height = msaa_texture!.height;
if (!scissor) {
render_pass.setScissorRect(0, 0, target_width, target_height);
return;
}
// webgpu errors on rects outside the target instead of clipping them
const x0 = Math.min(target_width, Math.max(0, Math.floor(scissor.x)));
const y0 = Math.min(target_height, Math.max(0, Math.floor(scissor.y)));
const x1 = Math.min(target_width, Math.max(x0, Math.ceil(scissor.x + scissor.width)));
const y1 = Math.min(target_height, Math.max(y0, Math.ceil(scissor.y + scissor.height)));
render_pass.setScissorRect(x0, y0, x1 - x0, y1 - y0);
}
function ensure_render_targets() {
const width = canvas.width;
const height = canvas.height;
if (msaa_texture && msaa_texture.width === width && msaa_texture.height === height) {
return;
}
msaa_texture?.destroy();
depth_texture?.destroy();
msaa_texture = device.createTexture({
size: [width, height],
format: canvas_format,
sampleCount: SAMPLE_COUNT,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
depth_texture = device.createTexture({
size: [width, height],
format: DEPTH_FORMAT,
sampleCount: SAMPLE_COUNT,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
}
// puts the current matrix in a new uniform slot, earlier draws in the frame keep using theirs
function write_mvp() {
uniform_slot += 1;
if (uniform_slot * UNIFORM_SLOT_SIZE >= uniform_buffer.size) {
pending_destroy.push(uniform_buffer);
create_uniform_buffer((uniform_buffer.size / UNIFORM_SLOT_SIZE) * 2);
uniform_slot = 0;
}
const matrix = mode3d ? mvp : ortho;
device.queue.writeBuffer(uniform_buffer, uniform_slot * UNIFORM_SLOT_SIZE, matrix as Float32Array);
}
function update_camera() {
if (!camera) {
return;
}
mat4.perspectiveZO(
proj,
camera.fov,
canvas.width / canvas.height,
camera.near,
camera.far,
);
mat4.identity(view);
mat4.rotateZ(view, view, -camera.roll);
mat4.rotateX(view, view, -camera.pitch);
mat4.rotateY(view, view, -camera.yaw);
mat4.translate(view, view, [-camera.x, -camera.y, -camera.z]);
mat4.multiply(mvp, proj, view);
}
function create_pipelines() {
const module = device.createShaderModule({ code: shader_src });
uniform_layout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 64 },
}],
});
texture_layout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
],
});
const layout = device.createPipelineLayout({ bindGroupLayouts: [uniform_layout, texture_layout] });
const vertex: GPUVertexState = {
module,
entryPoint: "vs_main",
buffers: [{
arrayStride: VERTEX_STRIDE,
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "float32x2" },
{ shaderLocation: 2, offset: 20, format: "float32x4" },
],
}],
};
const fragment: GPUFragmentState = {
module,
entryPoint: "fs_main",
targets: [{
format: canvas_format,
blend: {
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
},
}],
};
const multisample: GPUMultisampleState = { count: SAMPLE_COUNT };
pipeline_2d = device.createRenderPipeline({
layout,
vertex,
fragment,
multisample,
primitive: { topology: "triangle-list", cullMode: "none" },
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: 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) {
return device.createBuffer({ size, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
}
function create_uniform_buffer(slots: number) {
uniform_buffer = device.createBuffer({
size: slots * UNIFORM_SLOT_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
uniform_bind_group = device.createBindGroup({
layout: uniform_layout,
entries: [{ binding: 0, resource: { buffer: uniform_buffer, size: 64 } }],
});
}
function get_texture_bind_group(texture: GPUTexture) {
let bind_group = texture_bind_groups.get(texture);
if (!bind_group) {
bind_group = device.createBindGroup({
layout: texture_layout,
entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: sampler },
],
});
texture_bind_groups.set(texture, bind_group);
}
return bind_group;
}
function create_white_texture() {
const tex = create_texture(1, 1);
device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
white_tex = tex;
}