Files
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
2026-09-17 02:10:53 +03:00

896 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EditMeshController } from "./edit-mesh-controller.js";
import { captureEditMesh } from "./edit-mesh.js";
import {
multiply,
perspective,
viewMatrix,
key,
unitBox,
project,
} from "./math.js";
import { getBlockFaceTextures } from "./block-textures.js";
import { prepareTexturePack } from "./texture-pack.js";
import { boxVertices, buildSectionMesh } from "./mesh-geometry.js";
import { TerrainController } from "./terrain-controller.js";
import { affectedSectionKeys } from "./ambient-occlusion.js";
import { shadowFrame } from "./shadow-frame.js";
import { BlockLightController, inferLightBounds } from "./block-light-controller.js";
import { changedLightSections } from "./light-changes.js";
import { viewDistanceProfile } from "./view-distance.js";
import {
worldVertex as vertex, worldFragment as fragment,
skyVertex as skyVert, skyFragment as skyFrag,
shadowVertex, shadowFragment, SUN_DIRECTION,
} from "./lighting-shaders.js";
const lineVertex = `#version 300 es
precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;uniform vec3 uOffset;void main(){gl_Position=uVP*vec4(aPos+uOffset,1.);}`;
const lineFragment = `#version 300 es
precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`;
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, source] of [
[gl.VERTEX_SHADER, vs],
[gl.FRAGMENT_SHADER, fs],
]) {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
gl.deleteShader(s);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
throw Error(gl.getProgramInfoLog(p));
return p;
}
/** Avatar geometry follows the public physics pose; yaw remains interpolated. */
export function playerAvatarParts(player) {
const pose = player.pose || "standing",
swimming = pose === "swimming",
crouching = pose === "crouching",
defaultHeight = swimming ? 0.6 : crouching ? 1.5 : 1.8,
height = Number.isFinite(player.height)
? Math.max(0.4, Math.min(2.5, player.height))
: defaultHeight,
eye = Number.isFinite(player.eye_height)
? player.eye_height
: height - (swimming ? 0.2 : crouching ? 0.23 : 0.18),
shirt = player.color || [0.29, 0.46, 0.36],
skin = [0.77, 0.61, 0.43],
trousers = [0.25, 0.3, 0.32],
parts = [],
add = (part, min, max, color, material = 0) =>
parts.push({ part, box: { min, max }, color, material });
if (swimming) {
// The collider is short while the visible body lies along its facing axis.
add(
"torso",
[-0.21, height * 0.24, -0.36],
[0.21, height * 0.7, 0.28],
shirt,
2,
);
add("head", [-0.23, height * 0.2, -0.82], [0.23, height, -0.36], skin);
for (const side of [-1, 1]) {
add(
"leg",
[side * 0.13 - 0.085, height * 0.27, 0.28],
[side * 0.13 + 0.085, height * 0.57, 0.89],
trousers,
);
add(
"arm",
[side * 0.31 - 0.085, height * 0.22, -0.38],
[side * 0.31 + 0.085, height * 0.55, 0.24],
shirt,
);
}
add(
"eyes",
[-0.18, eye - 0.04, -0.824],
[0.18, eye + 0.04, -0.816],
[0.13, 0.19, 0.15],
);
} else {
const headBottom = height - 0.5,
hip = crouching ? headBottom - 0.55 : headBottom - 0.64,
lean = crouching ? -0.13 : 0;
add(
"torso",
[-0.21, hip, -0.13 + lean * 0.5],
[0.21, headBottom, 0.13 + lean * 0.5],
shirt,
2,
);
add(
"head",
[-0.23, headBottom, -0.23 + lean],
[0.23, height, 0.23 + lean],
skin,
);
for (const side of [-1, 1]) {
const knee = crouching ? 0.07 : 0;
add(
"leg",
[side * 0.13 - 0.085, 0.02, -0.09 + knee],
[side * 0.13 + 0.085, hip, 0.09 + knee],
trousers,
);
add(
"arm",
[side * 0.31 - 0.085, hip, -0.1 + lean],
[side * 0.31 + 0.085, headBottom - 0.03, 0.1 + lean],
shirt,
);
}
add(
"eyes",
[-0.18, eye - 0.04, -0.234 + lean],
[0.18, eye + 0.04, -0.226 + lean],
[0.13, 0.19, 0.15],
);
}
return parts;
}
const section = (p) => p.map((v) => Math.floor(v / 16)).join(",");
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.renderOrigin = [0,0,0]; this.selectionOrigin = [0,0,0];
const gl = (this.gl = canvas.getContext("webgl2", {
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
}));
if (!gl)
throw Error(
"WebGL2 недоступен. Включите аппаратное ускорение или откройте клиент в браузере с поддержкой WebGL2.",
);
this.program = program(gl, vertex, fragment);
this.sky = program(gl, skyVert, skyFrag);
this.line = program(gl, lineVertex, lineFragment);
this.shadowProgram = program(gl, shadowVertex, shadowFragment);
this.shadowSize = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE));
this.shadowRadius = 48;
this.shadowsEnabled = true;
this.shadowReady = this.createShadowMap();
this.lightingName = "Daylight";
this.daylight = 1;
this.sections = new Map();
this.publishedSections = new Set();
this.loadStarted = performance.now();
this.firstTerrainMs = null;
this.dirty = new Set();
this.blocks = new Map();
this.materials = new Map();
this.blockLightField = null;
this.lightViewBounds = null;
this.meshUpload = null;
this.terrain = new TerrainController({
onLight: field => { this.blockLightField = field; },
onDirty: ids => {
for (const id of ids) if (this.sectionInView(id)) this.dirty.add(id);
},
onError: () => this.enableTerrainFallback(),
});
if (this.terrain.error) {
// Some embedded browsers do not support workers. Keep the game usable
// through the former CPU path, and identify it in diagnostics.
this.terrain.dispose();
this.terrain = null;
this.blockLighting = new BlockLightController(field => {
for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id);
this.blockLightField = field;
});
} else this.blockLighting = this.terrain;
if (this.terrain) this.edits = new EditMeshController({
capture: id => captureEditMesh(id, this.blocks, this.materials, this.textureLayers, this.blockLightField),
onError: error => console.warn("Shacraft edit worker unavailable; using terrain queue", error),
});
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([180, 180, 180, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.textured = false;
this.effectTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([180, 180, 180, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.effectTextured = false;
this.blockTexture = this.createBlockTexture(1, 1);
this.blockAtlasGrid = [0, 0];
this.blockGrassOverlay = [-1, -1];
this.textureLayers = new Map();
this.faceTextureCache = new Map();
this.texturePackGeneration = 0;
this.bounceAt = -10000;
this.dynamic = this.mesh([]);
this.lines = this.mesh([], 3);
this.triangles = 0;
this.meshRebuilds = 0;
this.meshResets = 0;
this.vp = new Float32Array(16);
this.lost = false;
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault();
this.lost = true;
});
canvas.addEventListener("webglcontextrestored", () => location.reload());
}
createShadowMap() {
const gl = this.gl;
this.shadowTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24,
this.shadowSize, this.shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
this.shadowFramebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTexture, 0);
gl.drawBuffers([gl.NONE]);
gl.readBuffer(gl.NONE);
const ready = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return ready;
}
drawShadows(frame) {
if (!this.shadowsEnabled || !this.shadowReady) return;
const gl = this.gl, p = this.shadowProgram;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer);
gl.viewport(0, 0, this.shadowSize, this.shadowSize);
gl.colorMask(false, false, false, false);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1.1, 2.);
gl.useProgram(p);
gl.uniformMatrix4fv(gl.getUniformLocation(p, "uLightVP"), false, frame.matrix);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture);
gl.uniform1i(gl.getUniformLocation(p, "uBlockTextures"), 2);
gl.uniform2fv(gl.getUniformLocation(p, "uBlockAtlasGrid"), this.blockAtlasGrid);
gl.uniform2fv(gl.getUniformLocation(p, "uGrassOverlay"), this.blockGrassOverlay);
for (const mesh of this.sections.values()) {
this.meshOffset(p,mesh.origin);
gl.bindVertexArray(mesh.vao);
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
}
this.meshOffset(p,this.renderOrigin);
gl.bindVertexArray(this.dynamic.vao);
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.colorMask(true, true, true, true);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
meshOffset(program,origin=[0,0,0]) {
this.gl.uniform3fv(this.gl.getUniformLocation(program,"uOffset"),origin.map((v,i)=>v-this.renderOrigin[i]));
}
mesh(data, stride = 20) {
const gl = this.gl,
vao = gl.createVertexArray(),
buffer = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
for (const [loc, size, offset] of stride === 3
? [[0, 3, 0]]
: [
[0, 3, 0],
[1, 3, 3],
[2, 3, 6],
[3, 1, 9],
[4, 1, 10],
[5, 3, 11],
[6, 2, 14],
[7, 4, 16],
]) {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
loc,
size,
gl.FLOAT,
false,
stride * 4,
offset * 4,
);
}
return { vao, buffer, count: data.length / stride };
}
disposeMesh(mesh) {
if (mesh.transparent) this.disposeMesh(mesh.transparent);
this.gl.deleteBuffer(mesh.buffer);
this.gl.deleteVertexArray(mesh.vao);
}
upload(mesh, data, stride = 20) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
mesh.count = data.length / stride;
}
setBounceShader(source) {
if (source.length > 32768) throw Error("Шейдер пакета слишком велик");
const next = program(
this.gl,
vertex,
fragment.replace(
"vec3 shacraftBounceTint(vec3 color,float pulse){return color;}",
source,
),
);
this.gl.deleteProgram(this.program);
this.program = next;
}
async setTexture(blob, effect = false) {
const bitmap = await createImageBitmap(blob);
this.gl.bindTexture(
this.gl.TEXTURE_2D,
effect ? this.effectTexture : this.texture,
);
this.gl.texImage2D(
this.gl.TEXTURE_2D,
0,
this.gl.RGBA,
this.gl.RGBA,
this.gl.UNSIGNED_BYTE,
bitmap,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_S,
this.gl.REPEAT,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_T,
this.gl.REPEAT,
);
bitmap.close();
if (effect) this.effectTextured = true;
else this.textured = true;
}
createBlockTexture(size, layers, height = size) {
const gl = this.gl,
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, size, height, layers);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
return texture;
}
clearTexturePack() {
this.texturePackGeneration++;
this.blockAtlasGrid = [0, 0];
this.blockGrassOverlay = [-1, -1];
this.textureLayers.clear();
this.faceTextureCache.clear();
this.gl.deleteTexture(this.blockTexture);
this.blockTexture = this.createBlockTexture(1, 1);
this.invalidate();
}
async setTexturePack(descriptor, files) {
const gl = this.gl;
const {layers, width, height, atlas, entries, grassOverlay} = prepareTexturePack(descriptor, files, {
layers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS), size: gl.getParameter(gl.MAX_TEXTURE_SIZE),
});
const bitmaps = [];
const generation = ++this.texturePackGeneration;
let next;
try {
for (const entry of atlas ? [atlas] : entries) {
const bitmap = await createImageBitmap(files.get(entry.path), {
premultiplyAlpha: "none",
colorSpaceConversion: "none",
});
bitmaps.push(bitmap);
if (generation !== this.texturePackGeneration) return;
if (bitmap.width !== width || bitmap.height !== height)
throw Error(`Текстура ${entry.name || "atlas"} должна быть ${width}×${height}.`);
}
// Separate array layers prevent atlas bleeding; only native pixel data reaches the GPU.
next = this.createBlockTexture(width, bitmaps.length, height);
for (let i = 0; i < bitmaps.length; i++)
gl.texSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
i,
width,
height,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
bitmaps[i],
);
gl.deleteTexture(this.blockTexture);
this.blockTexture = next;
next = null;
this.textureLayers = layers;
this.blockAtlasGrid = atlas ? [atlas.columns, atlas.rows] : [0, 0];
this.blockGrassOverlay = grassOverlay;
this.faceTextureCache.clear();
this.invalidate();
} finally {
for (const bitmap of bitmaps) bitmap.close();
if (next) gl.deleteTexture(next);
}
}
faceTextures(mat) {
if (!this.textureLayers.size) return null;
if (!this.faceTextureCache.has(mat.state))
this.faceTextureCache.set(
mat.state,
getBlockFaceTextures(mat.state, this.textureLayers),
);
return this.faceTextureCache.get(mat.state);
}
replace(blocks, materials) {
this.meshResets++;
this.loadStarted = performance.now(); this.firstTerrainMs = null;
this.publishedSections?.clear();
this.edits?.reset();
for (const m of this.sections.values()) this.disposeMesh(m);
this.sections.clear();
this.dirty.clear();
this.blocks = blocks;
this.materials = materials;
this.blockLightField = null;
if (this.terrain) this.terrain.reset(blocks, materials, this.textureLayers, this.lightViewBounds);
else this.requestBlockLighting();
if (blocks.loadedSectionKeys) for (const id of blocks.loadedSectionKeys()) this.dirty.add(id);
else for (const k of blocks.keys()) this.dirty.add(section(k.split(",").map(Number)));
}
change(changes, sectionIds = null, sectionRecords = null) {
if (!sectionIds && changes.length > 64) this.edits?.reset();
const owners = changes.map(({pos}) => section(pos));
const urgent = !sectionIds && changes.length <= 64 ? [...new Set([...owners, ...changes.flatMap(({pos}) =>
affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]).filter(id => this.sections.has(id) || this.edits?.tickets.has(id)))])].filter(id => this.sectionInView(id)) : [];
if (changes.length || sectionRecords) {
if (this.terrain) this.terrain.update({ ...(sectionRecords ? { sections: sectionRecords } : { changes }), materials: this.materials });
else this.requestBlockLighting();
}
if (sectionIds) {
const affected = new Set();
// A stream packet already identifies whole entering/departing sections.
// Mark their halos once instead of repeating the same work per voxel.
for (const coords of sectionIds)
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const id = coords.map((v, axis) => v + [x, y, z][axis]).join(",");
if (this.sectionInView(id)) this.dirty.add(id);
affected.add(id);
}
this.edits?.request([...this.edits.tickets.keys()].filter(id => affected.has(id)));
return;
}
this.edits?.request(urgent);
for (const { pos } of changes) {
// Geometry and corner lighting can cross section edges diagonally.
for (const id of affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]))
this.dirty.add(id);
}
}
unloadSections(ids) {
if (ids.length) {
if (this.terrain) this.terrain.update({ unload: ids });
else this.requestBlockLighting();
}
const removed = new Set(ids.map((id) => id.join(",")));
for (const id of removed) {
this.edits?.cancel(id);
this.publishedSections?.delete(id);
const mesh = this.sections.get(id);
if (mesh) this.disposeMesh(mesh);
this.sections.delete(id);
this.dirty.delete(id);
}
// Retained face and diagonal neighbors may lose an occluder.
const refreshEdits = new Set();
for (const coords of ids)
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const neighbor = coords.map((v, axis) => v + [x, y, z][axis]).join(",");
if (!removed.has(neighbor) && this.sections.has(neighbor)) this.dirty.add(neighbor);
if (!removed.has(neighbor) && this.edits?.tickets.has(neighbor)) refreshEdits.add(neighbor);
}
this.edits?.request([...refreshEdits]);
}
invalidate() {
this.edits?.reset();
if (this.terrain) this.terrain.update({ materials: this.materials, textures: this.textureLayers });
else this.requestBlockLighting();
if (this.blocks.loadedSectionKeys) for (const id of this.blocks.loadedSectionKeys()) this.dirty.add(id);
else for (const k of this.blocks.keys()) this.dirty.add(section(k.split(",").map(Number)));
}
setLightBounds(bounds) {
if (JSON.stringify(bounds) === JSON.stringify(this.lightViewBounds)) return;
this.lightViewBounds = bounds;
if (this.terrain) this.terrain.update({ bounds });
else this.requestBlockLighting();
}
requestBlockLighting() {
if (this.terrain) { this.terrain.update({ materials: this.materials }); return; }
if (this.blockLighting)
this.blockLighting.request(this.blocks, this.materials, this.lightViewBounds || inferLightBounds(this.blocks));
}
enableTerrainFallback() {
if (!this.terrain) return;
this.terrain.dispose();
this.edits?.dispose();
this.discardMeshUpload();
this.terrain = null;
this.blockLighting = new BlockLightController(field => {
for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id);
this.blockLightField = field;
});
this.requestBlockLighting();
for (const key of this.blocks.keys()) this.dirty.add(section(key.split(",").map(Number)));
}
sectionInView(id) {
if (!this.lightViewBounds) return true;
return id.split(",").every((value, axis) => {
const base = Number(value) * 16;
return base <= this.lightViewBounds.max[axis] && base + 15 >= this.lightViewBounds.min[axis];
});
}
discardMeshUpload() {
if (!this.meshUpload) return;
for (const part of this.meshUpload.parts) if (part.mesh) this.disposeMesh(part.mesh);
this.meshUpload = null;
}
isMeshCurrent(result) {
return result.editJob !== undefined ? Boolean(this.edits?.isCurrent(result)) : this.terrain.isCurrent(result);
}
/** Upload at most 64 KiB per step; publish both surfaces only when complete. */
applyTerrainMeshes(budgetMs) {
const start = performance.now(), gl = this.gl;
if (this.meshUpload && !this.isMeshCurrent(this.meshUpload.result)) this.discardMeshUpload();
while (performance.now() - start < budgetMs) {
if (!this.meshUpload) {
const queue = this.edits?.ready.size ? this.edits.ready : this.terrain.ready;
const result = queue.values().next().value;
if (!result) break;
queue.delete(result.id);
if (!this.isMeshCurrent(result) || !this.sectionInView(result.id)) continue;
const parts = [result.vertices, result.transparent].map(data => ({ data, mesh: null, offset: 0 }));
this.meshUpload = { result, parts };
}
const pending = this.meshUpload;
const part = pending.parts.find(p => p.offset < p.data.length);
if (part) {
if (!part.mesh) {
part.mesh = this.mesh([]);
part.mesh.count = part.data.length / 20;
gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer);
gl.bufferData(gl.ARRAY_BUFFER, part.data.byteLength, gl.STATIC_DRAW);
if (performance.now() - start >= budgetMs) break;
}
const end = Math.min(part.offset + 16384, part.data.length);
gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, part.offset * 4, part.data.subarray(part.offset, end));
part.offset = end;
} else {
const { id } = pending.result, previous = this.sections.get(id);
const [opaque, transparent] = pending.parts;
if (opaque.mesh || transparent.mesh) {
const next = opaque.mesh || this.mesh([]);
next.transparent = transparent.mesh;
next.origin = pending.result.origin || [0,0,0];
if (previous) {
const retired = { vao: previous.vao, buffer: previous.buffer, transparent: previous.transparent };
Object.assign(previous, next);
this.disposeMesh(retired);
} else this.sections.set(id, next);
} else if (previous) { this.disposeMesh(previous); this.sections.delete(id); }
if (!pending.result.preview) { this.dirty.delete(id); this.edits?.cancel(id); }
this.publishedSections?.add(id);
if (this.firstTerrainMs === null && this.sections.has(id)) this.firstTerrainMs=performance.now()-this.loadStarted;
this.onMeshPublished?.(id, pending.result);
this.meshRebuilds++;
this.meshUpload = null;
}
}
this.meshUploadMilliseconds = performance.now() - start;
}
rebuild(id) {
this.meshRebuilds++;
const { vertices, transparent, origin } = buildSectionMesh({ id, blocks: this.blocks,
materials: this.materials, textureLayers: this.textureLayers,
faceTextureCache: this.faceTextureCache, blockLightField: this.blockLightField, localCoordinates: true });
let mesh = this.sections.get(id);
if (mesh) this.upload(mesh, vertices);
else if (vertices.length || transparent.length) {
mesh = this.mesh(vertices);
this.sections.set(id, mesh);
}
if (mesh) {
mesh.origin = origin;
if (mesh.transparent) this.upload(mesh.transparent, transparent);
else if (transparent.length) mesh.transparent = this.mesh(transparent);
}
if (mesh && vertices.length === 0 && transparent.length === 0) {
this.disposeMesh(mesh);
this.sections.delete(id);
}
}
updateSelection(hit) {
this.selectionOrigin = hit ? hit.pos.map(v=>Math.floor(v/16)*16) : [0,0,0];
const a = [];
if (hit) {
const mat = this.materials.get(hit.block),
boxes = mat?.render?.length ? mat.render : [unitBox];
for (const box of boxes) {
const points = [];
for (let i = 0; i < 8; i++)
points.push(
[0, 1, 2].map(
(k) =>
hit.pos[k] - this.selectionOrigin[k] +
((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
),
);
for (let i = 0; i < 8; i++)
for (let axis = 0; axis < 3; axis++)
if (!(i & (1 << axis)))
a.push(...points[i], ...points[i | (1 << axis)]);
}
}
this.upload(this.lines, a, 3);
}
draw(camera, players, entities, entityDefinitions) {
if (this.lost) return;
const distance = viewDistanceProfile(this.viewDistance);
const gl = this.gl,
ratio = Math.min(devicePixelRatio || 1, 1.75),
w = Math.floor(this.canvas.clientWidth * ratio),
h = Math.floor(this.canvas.clientHeight * ratio);
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
if (this.terrain) {
this.edits?.pump();
this.applyTerrainMeshes(2);
const eye = camera.eye;
let nearest = null, distance = Infinity;
if (!this.terrain.busy && !this.terrain.pending && this.terrain.ready.size < 2) for (const id of this.dirty) {
if (!this.sectionInView(id)) { this.dirty.delete(id); continue; }
if (this.blocks.readySections && !this.blocks.readySections.has(id)) continue;
if (this.blocks.sectionIsEmpty?.(id)) {
const mesh = this.sections.get(id);
if (mesh) this.disposeMesh(mesh);
this.sections.delete(id); this.dirty.delete(id); this.edits?.cancel(id);
this.publishedSections?.add(id);
continue;
}
if (this.terrain.ready.has(id) || this.meshUpload?.result.id === id || this.terrain.busy?.id === id) continue;
const center = id.split(",").map(v => Number(v) * 16 + 8);
const next = center.reduce((sum, v, axis) => sum + (v - eye[axis]) ** 2, 0);
if (next < distance) { distance = next; nearest = id; }
}
if (nearest !== null) this.terrain.requestMesh(nearest);
} else {
const start = performance.now();
for (const id of this.dirty) {
this.rebuild(id); this.dirty.delete(id);
if (performance.now() - start > 2) break;
}
}
this.renderOrigin = camera.eye.map(v=>Math.floor(v/16)*16);
const localEye = camera.eye.map((v,i)=>v-this.renderOrigin[i]);
const dynamic = [];
for (const p of players) {
for (const part of playerAvatarParts(p))
boxVertices(
dynamic,
p.position,
part.box,
part.color,
part.material,
() => false,
p.yaw || 0,
1, null, "", null, 0, this.blockLightField, true, this.renderOrigin,
);
}
for (const e of entities) {
const def = entityDefinitions.get(e.kind || e.name) || {},
color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
boxes = e.render ||
def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
for (const b of boxes)
boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0,
1, null, "", null, 0, this.blockLightField, true, this.renderOrigin);
}
this.upload(this.dynamic, dynamic);
const shadow = shadowFrame(localEye, SUN_DIRECTION, this.shadowSize, this.shadowRadius);
this.drawShadows(shadow);
this.canvas.dataset.lighting = this.lightingName;
const center=camera.eye.map(v=>Math.floor(v/16));
let nearReady=0,nearLoaded=0;
for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++) {
const id=`${center[0]+x},${center[1]+y},${center[2]+z}`;
if(this.publishedSections?.has(id))nearReady++;
if(this.blocks.readySections?.has(id))nearLoaded++;
}
this.canvas.dataset.nearMeshes=String(nearReady);
this.canvas.dataset.nearLoaded=String(nearLoaded);
this.canvas.dataset.firstTerrainMs=this.firstTerrainMs===null ? "" : String(Math.round(this.firstTerrainMs));
this.canvas.dataset.terrainMeshing = this.terrain?.meshWorker ? "local-light-worker" : "shared-worker";
this.canvas.dataset.localLightCells = String(this.terrain?.localLightCells || 0);
this.canvas.dataset.localLightMs = String(Math.round((this.terrain?.localLightMilliseconds || 0)*100)/100);
this.canvas.dataset.editPrepareMs = String(Math.round((this.edits?.prepareMilliseconds || 0) * 100) / 100);
this.canvas.dataset.editMeshMs = String(Math.round((this.edits?.meshMilliseconds || 0) * 100) / 100);
this.canvas.dataset.editWorker = this.edits && !this.edits.error && !this.edits.disposed ? "ready" : "fallback";
this.canvas.dataset.terrainMode = this.terrain ? "worker" : "main-thread-fallback";
this.canvas.dataset.terrainPrepareMs = String(Math.round((this.terrain?.prepareMilliseconds || 0) * 100) / 100);
this.canvas.dataset.meshBuildMs = String(Math.round((this.terrain?.meshMilliseconds || 0) * 100) / 100);
this.canvas.dataset.meshUploadMs = String(Math.round((this.meshUploadMilliseconds || 0) * 100) / 100);
this.canvas.dataset.timeOfDay = this.daylight === 0 ? "night" : "day";
this.canvas.dataset.shadows = this.shadowsEnabled && this.shadowReady ? String(this.shadowSize) : "off";
this.canvas.dataset.blockLighting = this.blockLighting.status;
this.canvas.dataset.lightSources = String(this.blockLightField?.sourceCount || 0);
this.canvas.dataset.lightBuildMs = String(Math.round(this.blockLighting.milliseconds || 0));
const eyeLight = this.blockLightField?.sample(camera.eye);
this.canvas.dataset.blockLight = String(eyeLight?.blockLevel ?? 0);
this.canvas.dataset.skyLight = String(eyeLight?.skyLevel ?? 15);
gl.viewport(0, 0, w, h);
gl.clearColor(0.65, 0.79, 0.8, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.useProgram(this.sky);
gl.uniform1f(gl.getUniformLocation(this.sky, "uDaylight"), this.daylight);
gl.uniform1f(gl.getUniformLocation(this.sky, "uSkyExposure"), ((eyeLight?.skyLevel ?? 15) / 15) ** 2);
gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch);
gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw);
gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h);
gl.uniform1f(gl.getUniformLocation(this.sky, "uTanHalfFov"), Math.tan(Math.PI / 5.4));
gl.bindVertexArray(null);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.vp = multiply(
perspective(Math.PI / 2.7, w / h, 0.06, distance.farClip),
viewMatrix(localEye, camera.yaw, camera.pitch),
);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.useProgram(this.program);
gl.uniform1f(gl.getUniformLocation(this.program, "uDaylight"), this.daylight);
gl.uniform1f(gl.getUniformLocation(this.program, "uFogDistance"), distance.fogDistance);
gl.uniform1f(gl.getUniformLocation(this.program, "uViewDistance"), distance.radius);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.program, "uVP"),
false,
this.vp,
);
gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), localEye);
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "uLightVP"), false, shadow.matrix);
gl.uniform3fv(gl.getUniformLocation(this.program, "uShadowCenter"), shadow.center);
gl.uniform1f(gl.getUniformLocation(this.program, "uShadowRadius"), shadow.radius);
gl.uniform1f(gl.getUniformLocation(this.program, "uShadowTexel"), 1 / this.shadowSize);
gl.uniform1i(gl.getUniformLocation(this.program, "uShadows"), this.shadowsEnabled && this.shadowReady ? 1 : 0);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uShadowMap"), 3);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0);
gl.uniform1i(
gl.getUniformLocation(this.program, "uTextured"),
this.textured ? 1 : 0,
);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uEffectTexture"), 1);
gl.uniform1i(
gl.getUniformLocation(this.program, "uEffectTextured"),
this.effectTextured ? 1 : 0,
);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uBlockTextures"), 2);
gl.uniform2fv(gl.getUniformLocation(this.program, "uBlockAtlasGrid"), this.blockAtlasGrid);
gl.uniform2fv(gl.getUniformLocation(this.program, "uGrassOverlay"), this.blockGrassOverlay);
gl.uniform1f(
gl.getUniformLocation(this.program, "uPulse"),
Math.max(0, 1 - (performance.now() - this.bounceAt) / 600),
);
this.triangles = 0;
for (const [id, m] of this.sections) {
const center = id.split(",").map((v) => Number(v) * 16 + 8);
if (
Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > distance.drawRadius
)
continue;
this.meshOffset(this.program,m.origin);
gl.bindVertexArray(m.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.count);
this.triangles += m.count / 3;
}
// Actor vertices already use the camera origin. Reset the world program's
// offset after terrain; its last section must never displace players/mobs.
this.meshOffset(this.program,this.renderOrigin);
gl.bindVertexArray(this.dynamic.vao);
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
this.triangles += this.dynamic.count / 3;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
const translucent = [...this.sections]
.filter(([, m]) => m.transparent?.count)
.sort((a, b) => {
const dist = (id) =>
Math.hypot(
...id.split(",").map((v, i) => Number(v) * 16 + 8 - camera.eye[i]),
);
return dist(b[0]) - dist(a[0]);
});
for (const [, m] of translucent) {
this.meshOffset(this.program,m.origin);
gl.bindVertexArray(m.transparent.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count);
this.triangles += m.transparent.count / 3;
}
gl.depthMask(true);
gl.disable(gl.BLEND);
if (this.lines.count) {
gl.useProgram(this.line);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.line, "uVP"),
false,
this.vp,
);
this.meshOffset(this.line,this.selectionOrigin);
gl.bindVertexArray(this.lines.vao);
gl.drawArrays(gl.LINES, 0, this.lines.count);
}
gl.bindVertexArray(null);
}
project(p) {
return project(
p.map((v,i)=>v-this.renderOrigin[i]),
this.vp,
this.canvas.clientWidth,
this.canvas.clientHeight,
);
}
}