Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s

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.
This commit is contained in:
Emil
2026-09-17 02:10:53 +03:00
parent 26748912c6
commit c7e86663d8
111 changed files with 24257 additions and 598 deletions
+564 -238
View File
@@ -1,88 +1,31 @@
import { EditMeshController } from "./edit-mesh-controller.js";
import { captureEditMesh } from "./edit-mesh.js";
import {
multiply,
perspective,
viewMatrix,
key,
isFullCube,
unitBox,
project,
} from "./math.js";
const vertex = `#version 300 es
precision highp float;layout(location=0) in vec3 aPos;layout(location=1) in vec3 aColor;layout(location=2) in vec3 aNormal;layout(location=3) in float aMaterial;layout(location=4) in float aOpacity;uniform mat4 uVP;out vec3 vPos;out vec3 vColor;out vec3 vNormal;flat out float vMaterial;out float vOpacity;void main(){vOpacity=aOpacity;vPos=aPos;vColor=aColor;vNormal=aNormal;vMaterial=aMaterial;gl_Position=uVP*vec4(aPos,1.);}`;
const fragment = `#version 300 es
precision highp float;in vec3 vPos;in vec3 vColor;in vec3 vNormal;flat in float vMaterial;in float vOpacity;uniform vec3 uEye;uniform sampler2D uTexture;uniform bool uTextured;uniform sampler2D uEffectTexture;uniform bool uEffectTextured;uniform float uPulse;out vec4 outColor;
float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}
vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
void main(){vec3 n=normalize(vNormal);vec2 uv=abs(n.y)>.5?vPos.xz:(abs(n.x)>.5?vPos.zy:vPos.xy);vec3 p=floor((vPos+n*.002)*16.);float noise=hash(p)*.14-.07;float pattern=1.;
if(vMaterial>1.5&&vMaterial<2.5){pattern=.89+.11*step(.07,fract(uv.y*4.));pattern*=.9+.1*step(.055,fract(uv.x*.5+floor(uv.y*4.)*.5));}
if(vMaterial>2.5&&vMaterial<3.5){pattern=.87+.13*step(.06,fract(uv.y*4.));pattern*=.84+.16*step(.045,fract(uv.x*2.+floor(uv.y*4.)*.5));}
if(vMaterial>3.5&&vMaterial<4.5){pattern=.91+.09*sin(floor(uv.x*16.)*1.73+sin(floor(uv.y*16.)*.22));}
float tex=uTextured?mix(.84,1.12,texture(uTexture,uv).r):1.;if(vMaterial>4.5&&uEffectTextured)tex=mix(.75,1.2,texture(uEffectTexture,uv).g);float diffuse=max(0.,dot(n,normalize(vec3(-.45,.86,.3))));float light=.69+diffuse*.34;vec3 color=clamp((vColor+noise)*pattern*tex*light,0.,1.);if(vMaterial>.5&&vMaterial<1.5&&n.y<.5)color=mix(color,vec3(.38,.29,.17),.65);
if(vMaterial>4.5)color=shacraftBounceTint(color,uPulse);float fog=1.-exp(-pow(distance(vPos,uEye)/74.,2.));color=mix(color,vec3(.70,.80,.79),fog*.91);outColor=vec4(color,vOpacity);}`;
const skyVert = `#version 300 es
precision highp float;out vec2 vUV;void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);vUV=p;gl_Position=vec4(p*2.-1.,1.,1.);}`;
const skyFrag = `#version 300 es
precision highp float;in vec2 vUV;uniform float uPitch;uniform float uYaw;uniform float uAspect;out vec4 outColor;void main(){float h=vUV.y+uPitch*.72;vec3 color=mix(vec3(.77,.83,.77),vec3(.38,.65,.78),smoothstep(.14,1.2,h));vec2 sun=vec2(.77-uYaw*.3,.82-uPitch*.72);float d=length((vUV-sun)*vec2(uAspect,1.));color=mix(color,vec3(1.,.98,.8),smoothstep(.048,.039,d)*.98);color+=vec3(.045,.025,0.)*exp(-d*7.);outColor=vec4(color,1.);}`;
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;void main(){gl_Position=uVP*vec4(aPos,1.);}`;
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.);}`;
const faces = [
{
n: [1, 0, 0],
v: [
[1, 0, 1],
[1, 0, 0],
[1, 1, 0],
[1, 1, 1],
],
},
{
n: [-1, 0, 0],
v: [
[0, 0, 0],
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
},
{
n: [0, 1, 0],
v: [
[0, 1, 1],
[1, 1, 1],
[1, 1, 0],
[0, 1, 0],
],
},
{
n: [0, -1, 0],
v: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0, 1],
],
},
{
n: [0, 0, 1],
v: [
[0, 0, 1],
[1, 0, 1],
[1, 1, 1],
[0, 1, 1],
],
},
{
n: [0, 0, -1],
v: [
[1, 0, 0],
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
],
},
];
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, source] of [
@@ -102,54 +45,100 @@ function program(gl, vs, fs) {
throw Error(gl.getProgramInfoLog(p));
return p;
}
function boxVertices(
out,
pos,
box,
color,
material = 0,
skip = () => false,
yaw = 0,
opacity = 1,
) {
for (let f = 0; f < 6; f++) {
if (skip(faces[f].n)) continue;
const face = faces[f],
c = Math.cos(yaw),
s = Math.sin(yaw),
n = [
face.n[0] * c - face.n[2] * s,
face.n[1],
face.n[0] * s + face.n[2] * c,
];
for (const i of [0, 1, 2, 0, 2, 3]) {
const v = face.v[i].map(
(v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
/** 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,
);
out.push(
pos[0] + v[0] * c - v[2] * s,
pos[1] + v[1],
pos[2] + v[0] * s + v[2] * c,
...color,
...n,
material,
opacity,
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],
);
}
}
function materialType(state = "", effect) {
if (effect === "bounce") return 5;
if (state.includes("grass_block")) return 1;
if (state.includes("planks")) return 2;
if (state.includes("brick")) return 3;
if (/log|stem|wood/.test(state)) return 4;
return 0;
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,
@@ -163,10 +152,44 @@ export class Renderer {
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(
@@ -199,10 +222,18 @@ export class Renderer {
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) => {
@@ -211,7 +242,63 @@ export class Renderer {
});
canvas.addEventListener("webglcontextrestored", () => location.reload());
}
mesh(data, stride = 11) {
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();
@@ -226,6 +313,9 @@ export class Renderer {
[2, 3, 6],
[3, 1, 9],
[4, 1, 10],
[5, 3, 11],
[6, 2, 14],
[7, 4, 16],
]) {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
@@ -244,7 +334,7 @@ export class Renderer {
this.gl.deleteBuffer(mesh.buffer);
this.gl.deleteVertexArray(mesh.vao);
}
upload(mesh, data, stride = 11) {
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);
@@ -291,61 +381,259 @@ export class Renderer {
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;
for (const k of blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
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) {
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) {
this.dirty.add(section(pos));
for (const f of faces)
this.dirty.add(section(pos.map((v, i) => v + f.n[i])));
// 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() {
for (const k of this.blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
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) {
const base = id.split(",").map((v) => Number(v) * 16),
vertices = [],
transparent = [];
for (let x = base[0]; x < base[0] + 16; x++)
for (let y = base[1]; y < base[1] + 16; y++)
for (let z = base[2]; z < base[2] + 16; z++) {
const pos = [x, y, z],
block = this.blocks.get(key(pos));
if (!block) continue;
const mat = this.materials.get(block) || {
color: [150, 152, 142],
render: [unitBox],
},
color = mat.color.map((v) => v / 255),
full = isFullCube(mat);
for (const box of mat.render || [unitBox]) {
boxVertices(
mat.opacity < 1 ? transparent : vertices,
pos,
box,
color,
materialType(mat.state, mat.effect),
(n) =>
full &&
isFullCube(
this.materials.get(
this.blocks.get(key(pos.map((v, i) => v + n[i]))),
),
),
0,
mat.opacity ?? 1,
);
}
}
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) {
@@ -353,6 +641,7 @@ export class Renderer {
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);
}
@@ -362,6 +651,7 @@ export class Renderer {
}
}
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),
@@ -372,7 +662,7 @@ export class Renderer {
points.push(
[0, 1, 2].map(
(k) =>
hit.pos[k] +
hit.pos[k] - this.selectionOrigin[k] +
((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
),
);
@@ -386,6 +676,7 @@ export class Renderer {
}
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),
@@ -394,40 +685,131 @@ export class Renderer {
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, 190),
viewMatrix(camera.eye, camera.yaw, camera.pitch),
perspective(Math.PI / 2.7, w / h, 0.06, distance.farClip),
viewMatrix(localEye, camera.yaw, camera.pitch),
);
const start = performance.now();
let rebuilt = 0;
for (const id of this.dirty) {
this.rebuild(id);
this.dirty.delete(id);
rebuilt++;
if (rebuilt >= 3 || performance.now() - start > 7) break;
}
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"), camera.eye);
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);
@@ -442,6 +824,11 @@ export class Renderer {
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),
@@ -450,80 +837,17 @@ export class Renderer {
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]) > 135
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;
}
const dynamic = [];
for (const p of players) {
const c = p.color || [0.29, 0.46, 0.36],
yaw = p.yaw || 0;
boxVertices(
dynamic,
p.position,
{ min: [-0.21, 0.63, -0.13], max: [0.21, 1.27, 0.13] },
c,
2,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{ min: [-0.23, 1.27, -0.23], max: [0.23, 1.73, 0.23] },
[0.77, 0.61, 0.43],
0,
() => false,
yaw,
);
for (const side of [-1, 1]) {
boxVertices(
dynamic,
p.position,
{
min: [side * 0.13 - 0.085, 0.02, -0.09],
max: [side * 0.13 + 0.085, 0.63, 0.09],
},
[0.25, 0.3, 0.32],
0,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{
min: [side * 0.31 - 0.085, 0.63, -0.1],
max: [side * 0.31 + 0.085, 1.24, 0.1],
},
c,
0,
() => false,
yaw,
);
}
boxVertices(
dynamic,
p.position,
{ min: [-0.18, 1.56, -0.234], max: [0.18, 1.64, -0.226] },
[0.13, 0.19, 0.15],
0,
() => false,
yaw,
);
}
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);
}
this.upload(this.dynamic, dynamic);
// 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;
@@ -540,6 +864,7 @@ export class Renderer {
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;
@@ -553,6 +878,7 @@ export class Renderer {
false,
this.vp,
);
this.meshOffset(this.line,this.selectionOrigin);
gl.bindVertexArray(this.lines.vao);
gl.drawArrays(gl.LINES, 0, this.lines.count);
}
@@ -560,7 +886,7 @@ export class Renderer {
}
project(p) {
return project(
p,
p.map((v,i)=>v-this.renderOrigin[i]),
this.vp,
this.canvas.clientWidth,
this.canvas.clientHeight,