MVP checks / mvp (push) Waiting to run
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.
896 lines
36 KiB
JavaScript
896 lines
36 KiB
JavaScript
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,
|
||
);
|
||
}
|
||
}
|